<?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-03-22T18:57:32+00:00</updated><author><name>Simon Willison</name></author><entry><title>Merge State Visualizer</title><link href="https://simonwillison.net/2026/Mar/22/manyana/#atom-tag" 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/#atom-tag</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/vcs"&gt;vcs&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/pyodide"&gt;pyodide&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/bram-cohen"&gt;bram-cohen&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/crdt"&gt;crdt&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="vcs"/><category term="pyodide"/><category term="bram-cohen"/><category term="crdt"/></entry><entry><title>cysqlite - a new sqlite driver</title><link href="https://simonwillison.net/2026/Feb/11/cysqlite/#atom-tag" 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/#atom-tag</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>Running Pydantic's Monty Rust sandboxed Python subset in WebAssembly</title><link href="https://simonwillison.net/2026/Feb/6/pydantic-monty/#atom-tag" 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/#atom-tag</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>MicroQuickJS</title><link href="https://simonwillison.net/2025/Dec/23/microquickjs/#atom-tag" 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/#atom-tag</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/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="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>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/#atom-tag" 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/#atom-tag</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;/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"/></entry><entry><title>Giving software away for free</title><link href="https://simonwillison.net/2025/Apr/28/give-it-away-for-free/#atom-tag" 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/#atom-tag</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>URL-addressable Pyodide Python environments</title><link href="https://simonwillison.net/2025/Feb/13/url-addressable-python/#atom-tag" 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/#atom-tag</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><entry><title>APSW SQLite query explainer</title><link href="https://simonwillison.net/2025/Feb/7/apsw-sqlite-query-explainer/#atom-tag" rel="alternate"/><published>2025-02-07T02:00:01+00:00</published><updated>2025-02-07T02:00:01+00:00</updated><id>https://simonwillison.net/2025/Feb/7/apsw-sqlite-query-explainer/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://tools.simonwillison.net/apsw-query"&gt;APSW SQLite query explainer&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Today I found out about &lt;a href="https://rogerbinns.github.io/apsw/"&gt;APSW&lt;/a&gt;'s (Another Python SQLite Wrapper, in constant development since 2004) &lt;a href="https://rogerbinns.github.io/apsw/ext.html#apsw.ext.query_info"&gt;apsw.ext.query_info()&lt;/a&gt; function, which takes a SQL query and returns a &lt;em&gt;very&lt;/em&gt; detailed set of information about that query - all without executing it.&lt;/p&gt;
&lt;p&gt;It actually solves a bunch of problems I've wanted to address in Datasette - like taking an arbitrary query and figuring out how many parameters (&lt;code&gt;?&lt;/code&gt;) it takes and which tables and columns are represented in the result.&lt;/p&gt;
&lt;p&gt;I tried it out in my console (&lt;code&gt;uv run --with apsw python&lt;/code&gt;) and it seemed to work really well. Then I remembered that the Pyodide project includes WebAssembly builds of a number of Python C extensions and was delighted to &lt;a href="https://pyodide.org/en/stable/usage/packages-in-pyodide.html"&gt;find apsw on that list&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;... so I &lt;a href="https://gist.github.com/simonw/8d79d2a4e746f7c8966d2ae1fea90cb3"&gt;got Claude&lt;/a&gt; to build me &lt;a href="https://tools.simonwillison.net/apsw-query"&gt;a web interface&lt;/a&gt; for trying out the function, using Pyodide to run a user's query in Python in their browser via WebAssembly.&lt;/p&gt;
&lt;p&gt;Claude didn't quite get it in one shot - I had to feed it the URL to a more recent Pyodide and it got stuck in a bug loop which I fixed by pasting the code into a fresh session.&lt;/p&gt;
&lt;p&gt;&lt;img alt="Screenshot of the tool. APSW SQLite query explainer. Query is select * from sqlite_master where tbl_name = ? and a parameter box below is set to example. Below is JSON with the query and a bunch of details about it." src="https://static.simonwillison.net/static/2025/apsw-explain.jpg" /&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/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/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/claude"&gt;claude&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude-artifacts"&gt;claude-artifacts&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/apsw"&gt;apsw&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/prompt-to-app"&gt;prompt-to-app&lt;/a&gt;&lt;/p&gt;



</summary><category term="python"/><category term="sqlite"/><category term="ai"/><category term="webassembly"/><category term="pyodide"/><category term="generative-ai"/><category term="llms"/><category term="ai-assisted-programming"/><category term="claude"/><category term="claude-artifacts"/><category term="apsw"/><category term="prompt-to-app"/></entry><entry><title>ChatGPT Canvas can make API requests now, but it's complicated</title><link href="https://simonwillison.net/2024/Dec/10/chatgpt-canvas/#atom-tag" rel="alternate"/><published>2024-12-10T21:49:55+00:00</published><updated>2024-12-10T21:49:55+00:00</updated><id>https://simonwillison.net/2024/Dec/10/chatgpt-canvas/#atom-tag</id><summary type="html">
    &lt;p&gt;Today's &lt;a href="https://openai.com/12-days/?day=4"&gt;12 Days of OpenAI&lt;/a&gt; release concerned &lt;a href="https://help.openai.com/en/articles/9930697-what-is-the-canvas-feature-in-chatgpt-and-how-do-i-use-it"&gt;ChatGPT Canvas&lt;/a&gt;, a new ChatGPT feature that enables ChatGPT to pop open a side panel with a shared editor in it where you can collaborate with ChatGPT on editing a document or writing code.&lt;/p&gt;
&lt;p&gt;I'm always excited to see a new form of UI on top of LLMs, and it's great seeing OpenAI stretch out beyond pure chat for this. It's definitely worth playing around with to get a feel for how a collaborative human+LLM interface can work. The feature where you can ask ChatGPT for "comments on my document" and it will attach them Google Docs style is particularly neat.&lt;/p&gt;
&lt;p&gt;I wanted to focus in on one particular aspect of Canvas, because it illustrates a concept I've been talking about for a little while now: the increasing complexity of fully understanding the capabilities of core LLM tools.&lt;/p&gt;
&lt;h4 id="canvas-runs-python-via-pyodide"&gt;Canvas runs Python via Pyodide&lt;/h4&gt;
&lt;p&gt;If a canvas editor contains Python code, ChatGPT adds a new "Run" button at the top of the editor.&lt;/p&gt;
&lt;p&gt;ChatGPT has had the ability to run Python for a long time via the excellent &lt;a href="https://simonwillison.net/tags/code-interpreter/"&gt;Code Interpreter&lt;/a&gt; feature, which executes Python server-side in a tightly locked down Kubernetes container managed by OpenAI.&lt;/p&gt;
&lt;p&gt;The new Canvas run button is &lt;strong&gt;not the same thing&lt;/strong&gt; - it's an entirely new implementation of code execution that runs code directly in your browser using &lt;a href="https://pyodide.org/"&gt;Pyodide&lt;/a&gt; (Python compiled to WebAssembly).&lt;/p&gt;
&lt;p&gt;The first time I tried this button I got the following dialog:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2024/run-python-code.jpg" alt="Run Python code? Python in canvas can make network requests and interact with external systems. Please review your code carefully before proceeding." style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;"Python in canvas can make network requests"‽ This is a &lt;em&gt;very new&lt;/em&gt; capability. ChatGPT Code Interpreter has all network access blocked, but apparently ChatGPT Canvas Python does not share that limitation.&lt;/p&gt;
&lt;p&gt;I tested this a little bit and it turns out it can make direct HTTP calls from your browser to anywhere online with compatible CORS headers.&lt;/p&gt;
&lt;p&gt;(Understanding CORS is &lt;a href="https://simonwillison.net/search/?q=cors&amp;amp;sort=date&amp;amp;tag=llms"&gt;a recurring theme&lt;/a&gt; in working with LLMs as a consumer, which I find deeply amusing because it remains a pretty obscure topic even among professional web developers.)&lt;/p&gt;
&lt;p&gt;&lt;a href="https://simonwillison.net/tags/claude-artifacts/"&gt;Claude Artifacts&lt;/a&gt; allow full JavaScript execution in a Canvas-like interface within Claude, but even those are severely restricted in terms of the endpoints they can access. OpenAI have apparently made the opposite decision, throwing everything wide open as far as allowed network request targets go.&lt;/p&gt;
&lt;p&gt;I prompted ChatGPT like this:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;use python to fetch "https://datasette.io/content.json?sql=select+*+from+stats++limit+10%0D%0A&amp;amp;_shape=array" and then display it nicely - the JSON looks like this:&lt;/code&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[
  {
    "package": "airtable-export",
    "date": "2020-12-14",
    "downloads": 2
  },
&lt;/code&gt;&lt;/pre&gt;
&lt;/blockquote&gt;
&lt;p&gt;I often find pasting the first few lines of a larger JSON example into an LLM gives it enough information to guess the rest.&lt;/p&gt;
&lt;p&gt;Here's the result. ChatGPT wrote the code and showed it in a canvas, then I clicked "Run" and had the resulting data displayed in a neat table below:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2024/canvas-python.jpg" alt="Two columns. On the left is my chat with my prompt. On the right Python code, with a table below showing the results of the API call." style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;What a neat and interesting thing! I can now get ChatGPT to write me Python code that fetches from external APIs and displays me the results.&lt;/p&gt;
&lt;p&gt;It's not yet as powerful as Claude Artifacts which allows for completely custom HTML+CSS+JavaScript interfaces, but it's also &lt;em&gt;more&lt;/em&gt; powerful than Artifacts because those are not allowed to make outbound HTTP requests at all.&lt;/p&gt;
&lt;h4 id="what-this-all-means"&gt;What this all means&lt;/h4&gt;
&lt;p&gt;With the introduction of Canvas, here are some new points that an expert user of ChatGPT now needs to understand:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;ChatGPT can write and then execute code in Python, but there are two different ways it can do that:
&lt;ul&gt;
&lt;li&gt;If run using Code Interpreter it can access files you upload to it and &lt;a href="https://github.com/simonw/scrape-openai-code-interpreter/blob/main/packages.txt"&gt;a collection of built-in libraries&lt;/a&gt; but cannot make API requests.&lt;/li&gt;
&lt;li&gt;If run in a Canvas it uses Pyodide and can access API endpoints, but not files that you upload to it.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Code Interpreter cannot &lt;code&gt;pip install&lt;/code&gt; additional packages, though you may be able to &lt;a href="https://til.simonwillison.net/llms/code-interpreter-expansions"&gt;upload them as wheels&lt;/a&gt; and convince it to install them.&lt;/li&gt;
&lt;li&gt;Canvas Python can install extra packages using &lt;a href="https://micropip.pyodide.org/en/stable/project/usage.html"&gt;micropip&lt;/a&gt;, but this will only work for pure Python wheels that are compatible with Pyodide.&lt;/li&gt;
&lt;li&gt;Code interpreter is locked down: it cannot make API requests or communicate with the wider internet at all. If you want it to work on data you need to upload that data to it.&lt;/li&gt;
&lt;li&gt;Canvas Python can fetch data via API requests (directly into your browser), but only from sources that implement an open CORS policy.&lt;/li&gt;
&lt;li&gt;Both Canvas and Code Interpreter remain strictly limited in terms of the custom UI they can offer - but they both have access to the Pandas ecosystem of visualization tools so they can probably show you charts or tables.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id="this-is-really-really-confusing"&gt;This is really, really confusing&lt;/h4&gt;
&lt;p&gt;Do you find this all hopelessly confusing? I don't blame you. I'm a professional web developer and a Python engineer of 20+ years and I can just about understand and internalize the above set of rules.&lt;/p&gt;
&lt;p&gt;I don't really have any suggestions for where we go from here. This stuff is &lt;em&gt;hard to use&lt;/em&gt;. The more features and capabilities we pile onto these systems the harder it becomes to obtain true mastery of them and really understand what they can do and how best to put them into practice.&lt;/p&gt;
&lt;p&gt;Maybe this doesn't matter? I don't know anyone with true mastery of Excel - to the point where they could compete in &lt;a href="https://fmworldcup.com/microsoft-excel-world-championship/"&gt;last week's Microsoft Excel World Championship&lt;/a&gt; - and yet plenty of people derive enormous value from Excel despite only scratching the surface of what it can do.&lt;/p&gt;
&lt;p&gt;I do think it's worth remembering this as a general theme though. Chatbots may sound easy to use, but they really aren't - and they're getting harder to use all the time.&lt;/p&gt;
&lt;h4 id="exfiltration"&gt;A new data exfiltration vector&lt;/h4&gt;
&lt;p&gt;Thinking about this a little more, I think the most meaningful potential security impact from this could be opening up a new data exfiltration vector.&lt;/p&gt;
&lt;p&gt;Data exfiltration attacks occur when an attacker tricks someone into pasting malicious instructions into their prompt (often via a &lt;a href="https://simonwillison.net/tags/prompt-injection/"&gt;prompt injection attack&lt;/a&gt;) that cause ChatGPT to gather up any available private information from the current conversation and leak it to that attacker in some way.&lt;/p&gt;
&lt;p&gt;I imagine it may be possible to construct a pretty gnarly attack that convinces ChatGPT to open up a Canvas and then run Python that leaks any gathered private data to the attacker via an API call.&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/security"&gt;security&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/usability"&gt;usability&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/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/prompt-injection"&gt;prompt-injection&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/claude-artifacts"&gt;claude-artifacts&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/cors"&gt;cors&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/prompt-to-app"&gt;prompt-to-app&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="python"/><category term="security"/><category term="usability"/><category term="ai"/><category term="webassembly"/><category term="pyodide"/><category term="openai"/><category term="prompt-injection"/><category term="generative-ai"/><category term="chatgpt"/><category term="llms"/><category term="claude-artifacts"/><category term="cors"/><category term="prompt-to-app"/></entry><entry><title>ChainForge</title><link href="https://simonwillison.net/2024/Nov/8/chainforge/#atom-tag" rel="alternate"/><published>2024-11-08T20:52:20+00:00</published><updated>2024-11-08T20:52:20+00:00</updated><id>https://simonwillison.net/2024/Nov/8/chainforge/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://www.chainforge.ai/"&gt;ChainForge&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
I'm still on the hunt for good options for running evaluations against prompts. ChainForge offers an interesting approach, calling itself "an open-source visual programming environment for prompt engineering".&lt;/p&gt;
&lt;p&gt;The interface is one of those boxes-and-lines visual programming tools, which reminds me of &lt;a href="https://en.wikipedia.org/wiki/Yahoo_Pipes"&gt;Yahoo Pipes&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://static.simonwillison.net/static/2024/chainforge.jpg"&gt;&lt;img alt="Screenshot of an AI model testing interface showing prompts, commands, and results. Left panel shows example commands and prompt injections. Center shows a Prompt Node with evaluation function checking for 'LOL' responses. Right panel displays a bar chart comparing success rates of prompt injection across models (PaLM2, Claude, GPT4, GPT3.5) with percentages shown on x-axis." src="https://static.simonwillison.net/static/2024/chainforge.jpg" /&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;It's open source (from a team at Harvard) and written in Python, which means you can run a local copy instantly via &lt;code&gt;uvx&lt;/code&gt; like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;uvx chainforge serve
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You can then configure it with API keys to various providers (OpenAI worked for me, Anthropic models returned JSON parsing errors due to a 500 page from the ChainForge proxy) and start trying it out.&lt;/p&gt;
&lt;p&gt;The "Add Node" menu shows the full list of capabilities.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://static.simonwillison.net/static/2024/chainforge-2.jpg"&gt;&lt;img alt="Left sidebar shows available nodes including TextFields Node, Prompt Node, and various evaluators. Main area shows connected nodes with input fields for Feet of Clay by Terry Pratchett and Rivers of London book one by Ben Aaronovitch, along with an Inspect Node displaying GPT4-mini's response about the opening sentence of Feet of Clay. A Prompt Node on the right queries What is the opening sentence of {book}? with options to query GPT4o-mini and claude-3-haiku models." src="https://static.simonwillison.net/static/2024/chainforge-2.jpg" /&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;The JavaScript and Python evaluation blocks are particularly interesting: the JavaScript one runs outside of a sandbox using plain &lt;code&gt;eval()&lt;/code&gt;, while the Python one still runs in your browser but uses Pyodide in a Web Worker.


    &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/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/prompt-engineering"&gt;prompt-engineering&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/prompt-injection"&gt;prompt-injection&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/evals"&gt;evals&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/uv"&gt;uv&lt;/a&gt;&lt;/p&gt;



</summary><category term="javascript"/><category term="python"/><category term="ai"/><category term="pyodide"/><category term="prompt-engineering"/><category term="prompt-injection"/><category term="generative-ai"/><category term="llms"/><category term="evals"/><category term="uv"/></entry><entry><title>Everything I built with Claude Artifacts this week</title><link href="https://simonwillison.net/2024/Oct/21/claude-artifacts/#atom-tag" rel="alternate"/><published>2024-10-21T14:32:57+00:00</published><updated>2024-10-21T14:32:57+00:00</updated><id>https://simonwillison.net/2024/Oct/21/claude-artifacts/#atom-tag</id><summary type="html">
    &lt;p&gt;I'm a huge fan of Claude's &lt;strong&gt;Artifacts&lt;/strong&gt; feature, which lets you prompt &lt;a href="https://claude.ai/"&gt;Claude&lt;/a&gt; to create an interactive Single Page App (using HTML, CSS and JavaScript) and then view the result directly in the Claude interface, iterating on it further with the bot and then, if you like, copying out the resulting code.&lt;/p&gt;
&lt;p&gt;I was digging around in my &lt;a href="https://support.anthropic.com/en/articles/9450526-how-can-i-export-my-claude-ai-data"&gt;Claude activity export&lt;/a&gt; (I built a &lt;a href="https://github.com/simonw/claude-to-sqlite"&gt;claude-to-sqlite&lt;/a&gt; tool to convert it to SQLite I could explore it in &lt;a href="https://datasette.io/"&gt;Datasette&lt;/a&gt;) and decided to see how much I'd used artifacts &lt;a href="https://github.com/simonw/claude-to-sqlite/issues/2#issuecomment-2425658909"&gt;in the past week&lt;/a&gt;. It was more than I expected!&lt;/p&gt;
&lt;p&gt;Being able to spin up a full interactive application - sometimes as an illustrative prototype, but often as something that directly solves a problem - is a remarkably useful tool.&lt;/p&gt;
&lt;p&gt;Here's most of what I've used Claude Artifacts for in the past seven days. I've provided prompts or a full transcript for nearly all of them.&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2024/Oct/21/claude-artifacts/#url-to-markdown-with-jina-reader"&gt;URL to Markdown with Jina Reader&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2024/Oct/21/claude-artifacts/#sqlite-in-wasm-demo"&gt;SQLite in WASM demo&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2024/Oct/21/claude-artifacts/#extract-urls"&gt;Extract URLs&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2024/Oct/21/claude-artifacts/#clipboard-viewer"&gt;Clipboard viewer&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2024/Oct/21/claude-artifacts/#pyodide-repl"&gt;Pyodide REPL&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2024/Oct/21/claude-artifacts/#photo-camera-settings-simulator"&gt;Photo Camera Settings Simulator&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2024/Oct/21/claude-artifacts/#llm-pricing-calculator"&gt;LLM pricing calculator&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2024/Oct/21/claude-artifacts/#yaml-to-json-converter"&gt;YAML to JSON converter&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2024/Oct/21/claude-artifacts/#openai-audio"&gt;OpenAI Audio&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2024/Oct/21/claude-artifacts/#qr-code-decoder"&gt;QR Code Decoder&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2024/Oct/21/claude-artifacts/#image-converter-and-downloader"&gt;Image Converter and Page Downloader&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2024/Oct/21/claude-artifacts/#html-entity-escaper"&gt;HTML Entity Escaper&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2024/Oct/21/claude-artifacts/#text-wrap-balance-nav"&gt;text-wrap-balance-nav&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2024/Oct/21/claude-artifacts/#ares-phonetic-alphabet-converter"&gt;ARES Phonetic Alphabet Converter&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id="url-to-markdown-with-jina-reader"&gt;URL to Markdown with Jina Reader&lt;/h4&gt;
&lt;p&gt;I got frustrated at how hard it was to copy and paste the entire text of a web page into an LLM while using Mobile Safari. So I built a simple web UI that lets me enter a URL, calls the &lt;a href="https://jina.ai/reader"&gt;Jina Reader API&lt;/a&gt; to generate Markdown (which uses Puppeteer under the hood) and gives me that Markdown with a convenient "Copy" button.&lt;/p&gt;
&lt;p&gt;Try it out: &lt;a href="https://tools.simonwillison.net/jina-reader"&gt;https://tools.simonwillison.net/jina-reader&lt;/a&gt; (&lt;a href="https://github.com/simonw/tools/blob/main/jina-reader.html"&gt;Code&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;&lt;img class="blogmark-image" style="max-width: 90%" src="https://static.simonwillison.net/static/2024/claude-artifacts/jina-reader.jpg" alt="Jina Reader - URL field, Markdown selected in a select box, Submit button. Then a box showing Markdown extracted from the page with a Copy to Clipboard button. Then a frame showing a preview of the rendered Markdown." /&gt;&lt;/p&gt;
&lt;p&gt;I wrote &lt;a href="https://simonwillison.net/2024/Oct/14/my-jina-reader-tool/?uu"&gt;more about that project here&lt;/a&gt;.&lt;/p&gt;
&lt;h4 id="sqlite-in-wasm-demo"&gt;SQLite in WASM demo&lt;/h4&gt;
&lt;p&gt;A Hacker News &lt;a href="https://news.ycombinator.com/item?id=41851051#41851788"&gt;conversation about SQLite's WASM build&lt;/a&gt; lead me to the &lt;a href="https://www.npmjs.com/package/@sqlite.org/sqlite-wasm"&gt;@sqlite.org/sqlite-wasm&lt;/a&gt; package on NPM, and I decided to knock together a quick interactive demo.&lt;/p&gt;
&lt;p&gt;&lt;img class="blogmark-image" style="max-width: 90%" src="https://static.simonwillison.net/static/2024/claude-artifacts/sqlite-wasm.jpg" alt="Pelican Sightings in Half Moon Bay - a textarea with select * from pelican sightings, an execute query button and a table displaying 5 matching rows." /&gt;&lt;/p&gt;
&lt;p&gt;Try it out here: &lt;a href="https://tools.simonwillison.net/sqlite-wasm"&gt;tools.simonwillison.net/sqlite-wasm&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/simonw/tools/blob/main/sqlite-wasm.html"&gt;Code&lt;/a&gt;, &lt;a href="https://gist.github.com/simonw/677c3794051c4dfeac94e514a8e5b697"&gt;Claude transcript&lt;/a&gt;&lt;/p&gt;

&lt;h4 id="extract-urls"&gt;Extract URLs&lt;/h4&gt;
&lt;p&gt;I found myself wanting to extract all of the underlying URLs that were linked to from a chunk of text on a web page. I realized the fastest way to do that would be to spin up an artifact that could accept rich-text HTML pastes and use an HTML parser to extract those links.&lt;/p&gt;
&lt;p&gt;&lt;img class="blogmark-image" style="max-width: 90%" src="https://static.simonwillison.net/static/2024/claude-artifacts/extract-urls.jpg" alt="Extract URLs tool. Content pasted. URLs extracted. Shows a list of extracted URLs." /&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href="https://tools.simonwillison.net/extract-urls"&gt;https://tools.simonwillison.net/extract-urls&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/simonw/tools/blob/main/extract-urls.html"&gt;Code&lt;/a&gt;, &lt;a href="https://gist.github.com/simonw/0a7d0ddeb0fdd63a844669475778ca06"&gt;Claude transcript&lt;/a&gt;&lt;/p&gt;
&lt;h4 id="clipboard-viewer"&gt;Clipboard viewer&lt;/h4&gt;
&lt;p&gt;Messing around with a tool that lets you paste in rich text reminded me that the browser clipboard API is a fascinating thing. I decided to build a quick debugging tool that would let me copy and paste different types of content (plain text, rich text, files, images etc) and see what information was available to me in the browser.&lt;/p&gt;
&lt;p&gt;&lt;img class="blogmark-image" style="max-width: 90%" src="https://static.simonwillison.net/static/2024/claude-artifacts/clipboard-viewer.jpg" alt="Clipboard format viewer. Paste here or anywhere on the page. Shows text/html with a fragment of HTML, text/plain with some text and Clipboard Event Information showing event type paste and formats available text/html and text/plain" /&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href="https://tools.simonwillison.net/clipboard-viewer"&gt;https://tools.simonwillison.net/clipboard-viewer&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/simonw/tools/blob/main/clipboard-viewer.html"&gt;Code&lt;/a&gt;, &lt;a href="https://gist.github.com/simonw/5393dd81fcabc9f854e8bbec205e7e1e"&gt;Claude transcript&lt;/a&gt;&lt;/p&gt;
&lt;h4 id="pyodide-repl"&gt;Pyodide REPL&lt;/h4&gt;
&lt;p&gt;I didn't put a lot of effort into this one. While poking around with Claude Artifacts in the browser DevTools I spotted this CSP header:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;content-security-policy: default-src https://www.claudeusercontent.com; script-src 'unsafe-eval' 'unsafe-inline' https://www.claudeusercontent.com https://cdnjs.cloudflare.com https://cdn.jsdelivr.net/pyodide/; connect-src https://cdn.jsdelivr.net/pyodide/; worker-src https://www.claudeusercontent.com blob:; style-src 'unsafe-inline' https://www.claudeusercontent.com https://cdnjs.cloudflare.com https://fonts.googleapis.com; img-src blob: data: https://www.claudeusercontent.com; font-src data: https://www.claudeusercontent.com; object-src 'none'; base-uri https://www.claudeusercontent.com; form-action https://www.claudeusercontent.com; frame-ancestors https://www.claudeusercontent.com https://claude.ai https://preview.claude.ai https://claude.site https://feedback.anthropic.com; upgrade-insecure-requests; block-all-mixed-content&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;https://cdn.jsdelivr.net/pyodide/&lt;/code&gt; in there caught my eye, because it suggested that the Anthropic development team had deliberately set it up so &lt;a href="https://pyodide.org/"&gt;Pyodide&lt;/a&gt; - Python compiled to WebAssembly - could be loaded in an artifact.&lt;/p&gt;
&lt;p&gt;I got Claude to spin up a very quick demo to prove that this worked:&lt;/p&gt;
&lt;p&gt;&lt;img class="blogmark-image" style="max-width: 90%" src="https://static.simonwillison.net/static/2024/claude-artifacts/pyodide-repl.jpg" alt="Pyodide Python REPL -   3 + 4 returns 7. A textarea to enter python code and a Run button." /&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href="https://claude.site/artifacts/a3f85567-0afc-4854-b3d3-3746dd1a37f2"&gt;https://claude.site/artifacts/a3f85567-0afc-4854-b3d3-3746dd1a37f2&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;I've not bothered to extract this one to my own &lt;code&gt;tools.simonwillison.net&lt;/code&gt; site yet because it's purely a proof of concept that Pyodide can load correctly in that environment.&lt;/p&gt;
&lt;h4 id="photo-camera-settings-simulator"&gt;Photo Camera Settings Simulator&lt;/h4&gt;
&lt;p&gt;I was out on a photo walk and got curious about whether or not JavaScript could provide a simulation of camera settings. I didn't get very far with this one (prompting on my phone while walking along the beach) - the result was buggy and unimpressive and I quickly lost interest. It did expose me to the &lt;a href="http://fabricjs.com/"&gt;Fabric.js&lt;/a&gt; library for manipulating canvas elements though.&lt;/p&gt;
&lt;p&gt;&lt;img class="blogmark-image" style="max-width: 90%" src="https://static.simonwillison.net/static/2024/claude-artifacts/photo-settings.jpg" alt="Photo Camera Settings Simulator. An image has been selected - but only the corner of the image displays with some buggy broken resize handles. Three sliders at the bottom show Exposure, Contrast and SAturation." /&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href="https://claude.site/artifacts/e645c231-8c13-4374-bb7d-271c8dd73825"&gt;https://claude.site/artifacts/e645c231-8c13-4374-bb7d-271c8dd73825&lt;/a&gt;&lt;/p&gt;
&lt;h4 id="llm-pricing-calculator"&gt;LLM pricing calculator&lt;/h4&gt;
&lt;p&gt;This one I &lt;em&gt;did&lt;/em&gt; finish. I built this pricing calculator as part of my experiments with &lt;a href="https://simonwillison.net/2024/Oct/17/video-scraping/"&gt;Video scraping using Google Gemini&lt;/a&gt;, because I didn't trust my own calculations for how inexpensive Gemini was! Here are &lt;a href="https://simonwillison.net/2024/Oct/17/video-scraping/#bonus-calculator"&gt;detailed notes&lt;/a&gt; on how I built that.&lt;/p&gt;
&lt;p&gt;&lt;img class="blogmark-image" style="max-width: 90%" src="https://static.simonwillison.net/static/2024/llm-pricing-calculator.jpg" alt="Screenshot of LLM Pricing Calculator interface. Left panel: input fields for tokens and costs. Input Tokens: 11018, Output Tokens: empty, Cost per Million Input Tokens: $0.075, Cost per Million Output Tokens: $0.3. Total Cost calculated: $0.000826 or 0.0826 cents. Right panel: Presets for various models including Gemini, Claude, and GPT versions with their respective input/output costs per 1M tokens. Footer: Prices were correct as of 16th October 2024, they may have changed." /&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href="https://tools.simonwillison.net/llm-prices"&gt;https://tools.simonwillison.net/llm-prices&lt;/a&gt;&lt;/p&gt;
&lt;h4 id="yaml-to-json-converter"&gt;YAML to JSON converter&lt;/h4&gt;
&lt;p&gt;I wanted to remind myself how certain aspects of YAML syntax worked, so I span up a quick YAML to JSON converter tool that shows the equivalent JSON live as you type YAML.&lt;/p&gt;
&lt;p&gt;&lt;img class="blogmark-image" style="max-width: 90%" src="https://static.simonwillison.net/static/2024/claude-artifacts/yaml-json.jpg" alt="YAML to JSON converter. In the top textarea is YAML. Below it is pretty-printed JSON output." /&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href="https://claude.site/artifacts/ffeb439c-fc95-428a-9224-434f5f968d51"&gt;https://claude.site/artifacts/ffeb439c-fc95-428a-9224-434f5f968d51&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href="https://gist.github.com/simonw/d861edb70a3572cb03de6f98a0caf3bc"&gt;Claude transcript&lt;/a&gt;&lt;/p&gt;
&lt;h4 id="openai-audio"&gt;OpenAI Audio&lt;/h4&gt;
&lt;p&gt;This is my most interesting artifact of the week. I was exploring OpenAI's new Audio APIs and decided to see if I could get Claude to build we a web page that could request access to my microphone, record a snippet of audio, then base64 encoded that and send it to the OpenAI API.&lt;/p&gt;
&lt;p&gt;Here are &lt;a href="https://simonwillison.net/2024/Oct/18/openai-audio/"&gt;the full details on how I built this tool&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;img class="blogmark-image" style="max-width: 90%" src="https://static.simonwillison.net/static/2024/openai-audio-card.jpg" alt="Screenshot of the OpenAI Audio tool. A start recording button is visible, and a 00:00 timer, and a playback audio element. There is a textarea for a prompt and a Submit to API button." /&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href="https://tools.simonwillison.net/openai-audio"&gt;https://tools.simonwillison.net/openai-audio&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Claude Artifacts can't make API requests to external hosts directly, but it can still spin up enough of a working version that it's easy to take that, move it to different hosting and finish getting it working.&lt;/p&gt;
&lt;p&gt;I wrote more about this API pattern in &lt;a href="https://simonwillison.net/2024/Aug/26/gemini-bounding-box-visualization/"&gt;Building a tool showing how Gemini Pro can return bounding boxes for objects in images&lt;/a&gt;.&lt;/p&gt;
&lt;h4 id="qr-code-decoder"&gt;QR Code Decoder&lt;/h4&gt;
&lt;p&gt;I was in a meeting earlier this week where one of the participants shared a slide with a QR code (for joining a live survey tool). I didn't have my phone with me, so I needed a way to turn that QR code into a regular URL.&lt;/p&gt;

&lt;p&gt;&lt;img class="blogmark-image" style="max-width: 90%" src="https://static.simonwillison.net/static/2024/claude-artifacts/qr.gif" alt="QR Code Decoder

Uses jsQR by Cosmo Wolfe

Upload, drag and drop, or paste a QR code image:
Select a file or drag and drop here

I drag on a QR code and the box says:

Decoded content: https://simonwillison.net/" /&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://tools.simonwillison.net/qr"&gt;https://tools.simonwillison.net/qr&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Knocking up this QR decoder in Claude Artifacts took just a few seconds:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Build an artifact (no react) that lets me paste in a QR code and displays the decoded information, with a hyperlink if necessary&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;[ ... ]&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;have a file open box that also lets you drag and drop and add a onpaste handler to the page that catches pasted images as well&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;a href="https://gist.github.com/simonw/c2b0c42cd1541d6ed6bfe5c17d638039"&gt;Full conversation here&lt;/a&gt;.&lt;/p&gt;
&lt;h4 id="image-converter-and-downloader"&gt;Image Converter and Page Downloader&lt;/h4&gt;
&lt;p&gt;Another very quick prototype. On Hacker News someone demonstrated a neat idea for a tool that let you drop photos onto a page and it would bake them into the page as base64 URLs such that you could "save as HTML" and get a self-contained page with a gallery.&lt;/p&gt;
&lt;p&gt;I &lt;a href="https://news.ycombinator.com/item?id=41876750#41880857"&gt;suggested they could add&lt;/a&gt; a feature that generated a "Download link" with the new page baked in - useful on mobile phones that don't let you "Save as HTML" - and got Claude to knock up a quick prototype:&lt;/p&gt;
&lt;p&gt;&lt;img class="blogmark-image" style="max-width: 90%" src="https://static.simonwillison.net/static/2024/claude-artifacts/image-converter-and-downloader.jpg" alt="Image converter and page downloader - I've selected an image and there is now a Download Page link below that image." /&gt;&lt;/p&gt;
&lt;p&gt;In this case I shared the code in &lt;a href="https://gist.github.com/egeozcan/b27e11a7e776972d18603222fa523ed4"&gt;a Gist&lt;/a&gt; and then used the new-to-me &lt;code&gt;https://gistpreview.github.io/?GIST_ID_GOES_HERE&lt;/code&gt; trick to render the result:&lt;/p&gt;
&lt;p&gt;&lt;a href="https://gistpreview.github.io/?14a2c3ef508839f26377707dbf5dd329"&gt;https://gistpreview.github.io/?14a2c3ef508839f26377707dbf5dd329&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/gistpreview/gistpreview.github.io"&gt;gistpreview&lt;/a&gt; turns out to be a really quick way to turn a LLM-generated demo into a page people can view.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://gist.github.com/egeozcan/b27e11a7e776972d18603222fa523ed4"&gt;Code&lt;/a&gt;, &lt;a href="https://gist.github.com/simonw/7026fe5051ba138eb15ef82f4936eaed"&gt;Claude transcript&lt;/a&gt;&lt;/p&gt;
&lt;h4 id="html-entity-escaper"&gt;HTML Entity Escaper&lt;/h4&gt;
&lt;p&gt;Another example of on-demand software: I needed to escape the HTML entities in a chunk of text on my phone, so I got Claude to build me a tool for that:&lt;/p&gt;
&lt;p&gt;&lt;img class="blogmark-image" style="max-width: 90%" src="https://static.simonwillison.net/static/2024/claude-artifacts/html-entities.jpg" alt="HTML entity escaper. In the input box I have typed in text with some double quotes. The output box has those correctly escaped, and a copy to clipboard button." /&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href="https://claude.site/artifacts/46897436-e06e-4ccc-b8f4-3df90c47f9bc"&gt;https://claude.site/artifacts/46897436-e06e-4ccc-b8f4-3df90c47f9bc&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Here's the prompt I used:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Build an artifact (no react) where I can paste text into a textarea and it will return that text with all HTML entities - single and double quotes and less than greater than ampersand - correctly escaped. The output should be in a textarea accompanied by a "Copy to clipboard" button which changes text to "Copied!" for 1.5s after you click it. Make it mobile friendly&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;a href="https://gist.github.com/simonw/77f91b65e29f43083f9510ae0c19a128"&gt;Claude transcript&lt;/a&gt;&lt;/p&gt;
&lt;h4 id="text-wrap-balance-nav"&gt;text-wrap-balance-nav&lt;/h4&gt;
&lt;p&gt;Inspired by &lt;a href="https://shkspr.mobi/blog/2024/10/you-can-use-text-wrap-balance-on-icons/"&gt;Terence Eden&lt;/a&gt; I decided to do a quick experiment with the &lt;code&gt;text-wrap: balance&lt;/code&gt; CSS property. I got Claude to build me an example nav bar with a slider and a checkbox. I &lt;a href="https://simonwillison.net/2024/Oct/20/you-can-use-text-wrap-balance-on-icons/"&gt;wrote about that here&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;img class="blogmark-image" style="max-width: 90%" alt="Animated demo. A navigation menu with 13 items - things like Home and About and Services and a products. These are wrapped on four lines with 4, 4, 4 and then 1 item. Selecting the enable text-wrap: balances checkbox changes that to 3, 4, 3, 3 - a slider also allows the number of visible items to be changed to see the effect that has" src="https://static.simonwillison.net/static/2024/text-wrap-balance.gif" /&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href="https://tools.simonwillison.net/text-wrap-balance-nav"&gt;https://tools.simonwillison.net/text-wrap-balance-nav&lt;/a&gt;&lt;/p&gt;
&lt;h4 id="ares-phonetic-alphabet-converter"&gt;ARES Phonetic Alphabet Converter&lt;/h4&gt;
&lt;p&gt;I was volunteering as a HAM radio communications operator for &lt;a href="https://hmbpumpkinfest.com/featured-exhibits/great-pumpkin-run.html"&gt;the Half Moon Bay Pumpkin Run&lt;/a&gt; and got nervous that I'd mess up using the phonetic alphabet - so I had Claude build me this tool:&lt;/p&gt;
&lt;p&gt;&lt;img class="blogmark-image" style="max-width: 90%" src="https://static.simonwillison.net/static/2024/claude-artifacts/phonetic-alphabet.jpg" alt="ARES PHonetic Alphabet Converter. I have entered the text Cleo is a lobster. After clicking the Convert button I get the output Charlie Lima Echo Oscar (Space) India Sierra (Space) Alpha (Space) Lima Oscar Sierra Tango Echo Romeo" /&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href="https://claude.site/artifacts/aaadab20-968a-4291-8ce9-6435f6d53f4c"&gt;https://claude.site/artifacts/aaadab20-968a-4291-8ce9-6435f6d53f4c&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href="https://gist.github.com/simonw/6ad4133c93e22df4c0ce731fdd7a2a91"&gt;Claude transcript here&lt;/a&gt;. Amusingly it built it in Python first, then switched to JavaScript after I reminded it that I wanted "an interactive web app".&lt;/p&gt;
&lt;h4 id="this-is-so-useful-and-so-much-fun-"&gt;This is so useful, and so much fun!&lt;/h4&gt;
&lt;p&gt;As you can see, I'm a &lt;em&gt;heavy&lt;/em&gt; user of this feature - I just described 14 projects produced in a single week. I've been using artifacts since they were released &lt;a href="https://simonwillison.net/2024/Jun/20/claude-35-sonnet/"&gt;on 20th June&lt;/a&gt; (alongside the excellent Claude 3.5 Sonnet, still my daily-driver LLM) and I'm now at a point where I fire up a new interactive artifact several times a day.&lt;/p&gt;
&lt;p&gt;I'm using artifacts for idle curiosity, rapid prototyping, library research and to spin up tools that solve immediate problems.&lt;/p&gt;
&lt;p&gt;Most of these tools took less than five minutes to build. A few of the more involved ones took longer than that, but even the OpenAI Audio one took &lt;a href="https://gist.github.com/simonw/0a4b826d6d32e4640d67c6319c7ec5ce"&gt;11:55am to 12:07pm&lt;/a&gt; for the first version and &lt;a href="https://gist.github.com/simonw/a04b844a5e8b01cecd28787ed375e738"&gt;12:18pm to 12:27pm&lt;/a&gt; for the second iteration - so 21 minutes total.&lt;/p&gt;
&lt;p&gt;Take a look at my &lt;a href="https://simonwillison.net/tags/claude-artifacts/"&gt;claude-artifacts&lt;/a&gt; tag for even more examples, including &lt;a href="https://simonwillison.net/2024/Oct/6/svg-to-jpg-png/"&gt;SVG to JPG/PNG&lt;/a&gt;, &lt;a href="https://simonwillison.net/2024/Sep/21/markdown-and-math-live-renderer/"&gt;Markdown and Math Live Renderer&lt;/a&gt; and &lt;a href="https://simonwillison.net/2024/Jul/26/image-resize-and-quality-comparison/"&gt;Image resize and quality comparison&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I also have a &lt;a href="https://simonwillison.net/2024/Oct/21/dashboard-tools/"&gt;dashboard&lt;/a&gt; of every post that links to my &lt;a href="https://tools.simonwillison.net/"&gt;tools.simonwillison.net&lt;/a&gt; site, and the underlying &lt;a href="https://github.com/simonw/tools"&gt;simonw/tools&lt;/a&gt; GitHub repo includes more unlisted tools, most of which link to their Claude conversation transcripts in their commit history.&lt;/p&gt;
&lt;p&gt;I'm beginning to get a little frustrated at their limitations - in particular the way artifacts are unable to make API calls, submit forms or even link out to other pages. I'll probably end up spinning up my own tiny artifacts alternative based on everything I've learned about them so far.&lt;/p&gt;
&lt;p&gt;If you're &lt;em&gt;not&lt;/em&gt; using artifacts, I hope I've given you a sense of why they're one of my current favourite LLM-based tools.&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/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/tools"&gt;tools&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/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/anthropic"&gt;anthropic&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude"&gt;claude&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude-artifacts"&gt;claude-artifacts&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude-3-5-sonnet"&gt;claude-3-5-sonnet&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/prompt-to-app"&gt;prompt-to-app&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="javascript"/><category term="projects"/><category term="tools"/><category term="ai"/><category term="pyodide"/><category term="generative-ai"/><category term="llms"/><category term="ai-assisted-programming"/><category term="anthropic"/><category term="claude"/><category term="claude-artifacts"/><category term="claude-3-5-sonnet"/><category term="prompt-to-app"/></entry><entry><title>marimo.app</title><link href="https://simonwillison.net/2024/Jun/29/marimo-app/#atom-tag" rel="alternate"/><published>2024-06-29T23:07:42+00:00</published><updated>2024-06-29T23:07:42+00:00</updated><id>https://simonwillison.net/2024/Jun/29/marimo-app/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://marimo.app/"&gt;marimo.app&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
The Marimo reactive notebook (&lt;a href="https://simonwillison.net/2024/Jan/12/marimo/"&gt;previously&lt;/a&gt;) - a Python notebook that's effectively a cross between Jupyter and Observable - now also has a version that runs entirely in your browser using WebAssembly and Pyodide. Here's &lt;a href="https://docs.marimo.io/guides/wasm.html"&gt;the documentation&lt;/a&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/jupyter"&gt;jupyter&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/observable"&gt;observable&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/marimo"&gt;marimo&lt;/a&gt;&lt;/p&gt;



</summary><category term="python"/><category term="jupyter"/><category term="observable"/><category term="webassembly"/><category term="pyodide"/><category term="marimo"/></entry><entry><title>Pyodide 0.26 Release</title><link href="https://simonwillison.net/2024/May/28/pyodide-026/#atom-tag" rel="alternate"/><published>2024-05-28T19:04:17+00:00</published><updated>2024-05-28T19:04:17+00:00</updated><id>https://simonwillison.net/2024/May/28/pyodide-026/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://blog.pyodide.org/posts/0.26-release/"&gt;Pyodide 0.26 Release&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
PyOdide provides Python packaged for browser WebAssembly alongside an ecosystem of additional tools and libraries to help Python and JavaScript work together.&lt;/p&gt;
&lt;p&gt;The latest release bumps the Python version up to 3.12, and also adds support for &lt;a href="https://github.com/pygame-community/pygame-ce"&gt;pygame-ce&lt;/a&gt;, allowing games written using pygame to run directly in the browser.&lt;/p&gt;
&lt;p&gt;The PyOdide community also &lt;a href="https://github.com/pypa/cibuildwheel/pull/1456"&gt;just landed&lt;/a&gt; a 14-month-long PR adding support to cibuildwheel, which should make it easier to ship binary wheels targeting PyOdide.

    &lt;p&gt;&lt;small&gt;&lt;/small&gt;Via &lt;a href="https://twitter.com/pyodide/status/1795420504511123523"&gt;@pyodide&lt;/a&gt;&lt;/small&gt;&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/pygame"&gt;pygame&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/python"&gt;python&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="javascript"/><category term="pygame"/><category term="python"/><category term="webassembly"/><category term="pyodide"/></entry><entry><title>Bringing Python to Workers using Pyodide and WebAssembly</title><link href="https://simonwillison.net/2024/Apr/2/cloudflare-python-workers/#atom-tag" rel="alternate"/><published>2024-04-02T16:09:57+00:00</published><updated>2024-04-02T16:09:57+00:00</updated><id>https://simonwillison.net/2024/Apr/2/cloudflare-python-workers/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://blog.cloudflare.com/python-workers"&gt;Bringing Python to Workers using Pyodide and WebAssembly&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Cloudflare Workers is Cloudflare’s serverless hosting tool for deploying server-side functions to edge locations in their CDN.&lt;/p&gt;

&lt;p&gt;They just released Python support, accompanied by an extremely thorough technical explanation of how they got that to work. The details are fascinating.&lt;/p&gt;

&lt;p&gt;Workers runs on V8 isolates, and the new Python support was implemented using Pyodide (CPython compiled to WebAssembly) running inside V8.&lt;/p&gt;

&lt;p&gt;Getting this to work performantly and ergonomically took a huge amount of work.&lt;/p&gt;

&lt;p&gt;There are too many details in here to effectively summarize, but my favorite detail is this one:&lt;/p&gt;

&lt;p&gt;“We scan the Worker’s code for import statements, execute them, and then take a snapshot of the Worker’s WebAssembly linear memory. Effectively, we perform the expensive work of importing packages at deploy time, rather than at runtime.”

    &lt;p&gt;&lt;small&gt;&lt;/small&gt;Via &lt;a href="https://news.ycombinator.com/item?id=39905441"&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/serverless"&gt;serverless&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/cloudflare"&gt;cloudflare&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="python"/><category term="serverless"/><category term="cloudflare"/><category term="webassembly"/><category term="pyodide"/></entry><entry><title>urllib3 2.2.0</title><link href="https://simonwillison.net/2024/Jan/30/urllib3/#atom-tag" rel="alternate"/><published>2024-01-30T16:31:00+00:00</published><updated>2024-01-30T16:31:00+00:00</updated><id>https://simonwillison.net/2024/Jan/30/urllib3/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/urllib3/urllib3/releases/tag/2.2.0"&gt;urllib3 2.2.0&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Highlighted feature: “urllib3 now works in the browser”—the core urllib3 library now includes code that can integrate with Pyodide, using the browser’s fetch() or XMLHttpRequest APIs to make HTTP requests (to CORS-enabled endpoints).


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/python"&gt;python&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/cors"&gt;cors&lt;/a&gt;&lt;/p&gt;



</summary><category term="python"/><category term="webassembly"/><category term="pyodide"/><category term="cors"/></entry><entry><title>Weeknotes: sqlite-utils 3.31, download-esm, Python in a sandbox</title><link href="https://simonwillison.net/2023/May/10/weeknotes/#atom-tag" rel="alternate"/><published>2023-05-10T22:07:01+00:00</published><updated>2023-05-10T22:07:01+00:00</updated><id>https://simonwillison.net/2023/May/10/weeknotes/#atom-tag</id><summary type="html">
    &lt;p&gt;A couple of speaking appearances last week - one planned, one unplanned. Plus &lt;code&gt;sqlite-utils 3.31&lt;/code&gt;, &lt;code&gt;download-esm&lt;/code&gt; and a new TIL.&lt;/p&gt;
&lt;h4&gt;Prompt injection video, Leaked Google document audio&lt;/h4&gt;
&lt;p&gt;I participated in the LangChain webinar about prompt injection. The session was recorded, so I extracted my 12 minute introduction to the topic and turned it into &lt;a href="https://simonwillison.net/2023/May/2/prompt-injection-explained/"&gt;a blog post&lt;/a&gt; complete with a Whisper transcription, a video and the slides I used in the talk.&lt;/p&gt;
&lt;p&gt;Then on Thursday I wrote about &lt;a href="https://simonwillison.net/2023/May/4/no-moat/"&gt;the leaked internal Google document&lt;/a&gt; that argued that Google and OpenAI have no meaningful moat given the accelerating pace of open source LLM research.&lt;/p&gt;
&lt;p&gt;This lead to a last minute invitation to participate in a Latent Space Twitter Space about the document, which is now available &lt;a href="https://www.latent.space/p/no-moat#details"&gt;as a podcast&lt;/a&gt;.&lt;/p&gt;
&lt;h4&gt;sqlite-utils 3.31&lt;/h4&gt;
&lt;p&gt;I realized that &lt;a href="https://sqlite-utils.datasette.io/"&gt;sqlite-utils&lt;/a&gt; had been quietly accumulating small fixes and pull requests since the 3.30 release last October, and spent a day tidying those up and turning them into a release.&lt;/p&gt;
&lt;p&gt;Notably, four contributors get credited in &lt;a href="https://sqlite-utils.datasette.io/en/stable/changelog.html#v3-31"&gt;the release notes&lt;/a&gt;: Chris Amico, Kenny Song, Martin Carpenter and Scott Perry.&lt;/p&gt;
&lt;p&gt;Key changes are listed below:&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;Automatically locates the SpatiaLite extension on Apple Silicon. Thanks, Chris Amico. (&lt;a href="https://github.com/simonw/sqlite-utils/pull/536"&gt;#536&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;New &lt;code&gt;--raw-lines&lt;/code&gt; option for the &lt;code&gt;sqlite-utils query&lt;/code&gt; and &lt;code&gt;sqlite-utils memory&lt;/code&gt; commands, which outputs just the raw value of the first column of evy row. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/539"&gt;#539&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;Fixed a bug where &lt;code&gt;table.upsert_all()&lt;/code&gt; failed if the &lt;code&gt;not_null=&lt;/code&gt; option was passed. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/538"&gt;#538&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;table.convert(..., skip_false=False)&lt;/code&gt; and &lt;code&gt;sqlite-utils convert --no-skip-false&lt;/code&gt; options, for avoiding a misfeature where the &lt;a href="http://127.0.0.1:8000/python-api.html#python-api-convert"&gt;convert()&lt;/a&gt; mechanism skips rows in the database with a falsey value for the specified column. Fixing this by default would be a backwards-incompatible change and is under consideration for a 4.0 release in the future. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/527"&gt;#527&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;Tables can now be created with self-referential foreign keys. Thanks, Scott Perry. (&lt;a href="https://github.com/simonw/sqlite-utils/pull/537"&gt;#537&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sqlite-utils transform&lt;/code&gt; no longer breaks if a table defines default values for columns. Thanks, Kenny Song. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/509"&gt;#509&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;Fixed a bug where repeated calls to &lt;code&gt;table.transform()&lt;/code&gt; did not work correctly. Thanks, Martin Carpenter. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/525"&gt;#525&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;h4&gt;download-esm&lt;/h4&gt;
&lt;p&gt;As part of my ongoing mission to figure out how to write modern JavaScript without surrendering to one of the many different JavaScript build tools, I built &lt;a href="https://github.com/simonw/download-esm"&gt;download-esm&lt;/a&gt; - a Python CLI tool for downloading the ECMAScript module versions of an npm package along with all of their module dependencies.&lt;/p&gt;
&lt;p&gt;I wrote more about my justification for building that tool in &lt;a href="https://simonwillison.net/2023/May/2/download-esm/"&gt;download-esm: a tool for downloading ECMAScript modules&lt;/a&gt;.&lt;/p&gt;
&lt;h4&gt;Running Python in a Deno/Pyodide sandbox&lt;/h4&gt;
&lt;p&gt;I'm still trying to find the best way to run untrusted Python code in a safe WebAssembly sandbox.&lt;/p&gt;
&lt;p&gt;My latest attempt takes advantage of Pyodide and Deno. It was inspired by &lt;a href="https://github.com/pyodide/pyodide/issues/3420#issuecomment-1542742906"&gt;this comment&lt;/a&gt; by Milan Raj, showing how Deno can load Pyodide now. Pyodide was previously only available in web browsers.&lt;/p&gt;
&lt;p&gt;I came up with a somewhat convoluted mechanism that starts a Deno process running in a Python &lt;code&gt;subprocess&lt;/code&gt; and then runs Pyodide inside of Deno.&lt;/p&gt;
&lt;p&gt;See &lt;a href="https://til.simonwillison.net/deno/pyodide-sandbox"&gt;Running Python code in a Pyodide sandbox via Deno&lt;/a&gt; for the code and my thoughts on next steps for that prototype.&lt;/p&gt;
&lt;h4&gt;Blog entries this week&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://simonwillison.net/2023/May/4/no-moat/"&gt;Leaked Google document: "We Have No Moat, And Neither Does OpenAI"&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://simonwillison.net/2023/May/4/midjourney-51/"&gt;Midjourney 5.1&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://simonwillison.net/2023/May/2/prompt-injection-explained/"&gt;Prompt injection explained, with video, slides, and a transcript&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://simonwillison.net/2023/May/2/download-esm/"&gt;download-esm: a tool for downloading ECMAScript modules&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://simonwillison.net/2023/May/1/lets-be-bear-or-bunny/"&gt;Let's be bear or bunny&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;Releases this week&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/simonw/sqlite-utils/releases/tag/3.31"&gt;sqlite-utils 3.31&lt;/a&gt;&lt;/strong&gt; - 2023-05-08&lt;br /&gt;Python CLI utility and library for manipulating SQLite databases&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;TIL this week&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://til.simonwillison.net/deno/pyodide-sandbox"&gt;Running Python code in a Pyodide sandbox via Deno&lt;/a&gt; - 2023-05-10&lt;/li&gt;
&lt;/ul&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/weeknotes"&gt;weeknotes&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/deno"&gt;deno&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sqlite-utils"&gt;sqlite-utils&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/pyodide"&gt;pyodide&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="projects"/><category term="python"/><category term="weeknotes"/><category term="deno"/><category term="sqlite-utils"/><category term="pyodide"/></entry><entry><title>PyScript Updates: Bytecode Alliance, Pyodide, and MicroPython</title><link href="https://simonwillison.net/2022/Nov/9/pyscript-updates-bytecode-alliance-pyodide-and-micropython/#atom-tag" rel="alternate"/><published>2022-11-09T22:26:09+00:00</published><updated>2022-11-09T22:26:09+00:00</updated><id>https://simonwillison.net/2022/Nov/9/pyscript-updates-bytecode-alliance-pyodide-and-micropython/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://www.anaconda.com/blog/pyscript-updates-bytecode-alliance-pyodide-and-micropython"&gt;PyScript Updates: Bytecode Alliance, Pyodide, and MicroPython&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Absolutely huge news about Python on the Web tucked into this announcement: Anaconda have managed to get a version of MicroPython compiled to WebAssembly running in the browser. Pyodide weighs in at around 6.5MB compressed, but the MicroPython build is just 303KB—the size of a large image. This makes Python in the web browser applicable to so many more potential areas.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/python"&gt;python&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="python"/><category term="webassembly"/><category term="pyodide"/></entry><entry><title>Plugin support for Datasette Lite</title><link href="https://simonwillison.net/2022/Aug/17/datasette-lite-plugins/#atom-tag" rel="alternate"/><published>2022-08-17T18:20:59+00:00</published><updated>2022-08-17T18:20:59+00:00</updated><id>https://simonwillison.net/2022/Aug/17/datasette-lite-plugins/#atom-tag</id><summary type="html">
    &lt;p&gt;I've added a new feature to &lt;a href="https://lite.datasette.io/"&gt;Datasette Lite&lt;/a&gt;, my distribution of &lt;a href="https://datasette.io/"&gt;Datasette&lt;/a&gt; that runs entirely in the browser using Python and SQLite compiled to WebAssembly. You can now install additional &lt;a href="https://datasette.io/plugins"&gt;Datasette plugins&lt;/a&gt; by passing them in the URL.&lt;/p&gt;
&lt;h4&gt;Datasette Lite background&lt;/h4&gt;
&lt;p&gt;Datasette Lite runs Datasette in the browser. I initially built it as a fun technical proof of concept, but I'm increasingly finding it to be a genuinely useful tool for quick ad-hoc data analysis and publication. Not having any server-side components at all makes it effectively free to use without fear of racking up cloud computing costs for a throwaway project.&lt;/p&gt;
&lt;p&gt;You can read more about Datasette Lite in these posts:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://simonwillison.net/2022/May/4/datasette-lite/"&gt;Datasette Lite: a server-side Python web application running in a browser&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://simonwillison.net/2022/Jun/20/datasette-lite-csvs/"&gt;Joining CSV files in your browser using Datasette Lite&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://simonwillison.net/2022/Jul/30/psf-board/#scraping-datasette-lite"&gt;Scraping data into Datasette Lite&lt;/a&gt; shows an example project where I scraped PSF board resolutions, stored the results in a CSV file in &lt;a href="https://gist.github.com/simonw/71b03ca3688c8f37fe1b35859ebc458b"&gt;a GitHub Gist&lt;/a&gt; and then constructed &lt;a href="https://lite.datasette.io/?csv=https%3A%2F%2Fgist.githubusercontent.com%2Fsimonw%2F71b03ca3688c8f37fe1b35859ebc458b%2Fraw%2Fd3ca708e5d6242848201d1d4c1c2ddbba15c4d28%2Fpsf-resolutions.csv#/data?sql=with+filtered+as+%28%0A++select+*+from%0A++++%5Bpsf-resolutions%5D%0A++where%0A++++%22dollars%22+is+not+null%0A++++and+%22text%22+like+%27%25%27+%7C%7C+%3Asearch+%7C%7C+%27%25%27%0A%29%0Aselect%0A++%27Total%3A+%24%27+%7C%7C+printf%28%27%25%2Cd%27%2C+sum%28dollars%29%29+as+text%2C%0A++null+as+date%0Afrom+filtered%0Aunion+all%0Aselect%0A++text%2C+date%0Afrom+filtered%3B&amp;amp;search=Nigeria"&gt;this URL&lt;/a&gt; to open the result in Datasette Lite and execute a SQL query.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;Adding plugins to Datasette Lite&lt;/h4&gt;
&lt;p&gt;One of Datasette's key features is support for plugins. There are over 90 listed in the &lt;a href="https://datasette.io/plugins"&gt;plugin directory&lt;/a&gt; now, with more emerging all the time. They're a fantastic way to explore new feature ideas and extend the software to handle non-default use cases.&lt;/p&gt;
&lt;p&gt;Plugins are Python packages, published to &lt;a href="https://pypi.org/"&gt;PyPI&lt;/a&gt;. You can add them to Datasette Lite using the new &lt;code&gt;?install=name-of-plugin&lt;/code&gt; query string parameter.&lt;/p&gt;
&lt;p&gt;Here's an example URL that loads the &lt;a href="https://datasette.io/plugins/datasette-jellyfish"&gt;datasette-jellyfish&lt;/a&gt; plugin, which adds new SQL functions for calculating distances between strings, then executes a SQL query that demonstrates that plugin:&lt;/p&gt;
&lt;p&gt;&lt;a href="https://lite.datasette.io/?install=datasette-jellyfish#/fixtures?sql=SELECT%0A++++levenshtein_distance%28%3As1%2C+%3As2%29%2C%0A++++damerau_levenshtein_distance%28%3As1%2C+%3As2%29%2C%0A++++hamming_distance%28%3As1%2C+%3As2%29%2C%0A++++jaro_similarity%28%3As1%2C+%3As2%29%2C%0A++++jaro_winkler_similarity%28%3As1%2C+%3As2%29%2C%0A++++match_rating_comparison%28%3As1%2C+%3As2%29%3B&amp;amp;s1=barrack+obama&amp;amp;s2=barrack+h+obama"&gt;https://lite.datasette.io/?install=datasette-jellyfish#/fixtures?sql=SELECT%0A++++levenshtein_distance%28%3As1%2C+%3As2%29%2C%0A++++damerau_levenshtein_distance%28%3As1%2C+%3As2%29%2C%0A++++hamming_distance%28%3As1%2C+%3As2%29%2C%0A++++jaro_similarity%28%3As1%2C+%3As2%29%2C%0A++++jaro_winkler_similarity%28%3As1%2C+%3As2%29%2C%0A++++match_rating_comparison%28%3As1%2C+%3As2%29%3B&amp;amp;s1=barrack+obama&amp;amp;s2=barrack+h+obama&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;That URL uses &lt;code&gt;?install=datasette-jellyfish&lt;/code&gt; to install the plugin, then executes the following SQL query:&lt;/p&gt;
&lt;div class="highlight highlight-source-sql"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;SELECT&lt;/span&gt;
    levenshtein_distance(:s1, :s2),
    damerau_levenshtein_distance(:s1, :s2),
    hamming_distance(:s1, :s2),
    jaro_similarity(:s1, :s2),
    jaro_winkler_similarity(:s1, :s2),
    match_rating_comparison(:s1, :s2);&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;It sets &lt;code&gt;s1&lt;/code&gt; to &lt;code&gt;"barack obama"&lt;/code&gt; and &lt;code&gt;s2&lt;/code&gt; to &lt;code&gt;"barrack h obama&lt;/code&gt;".&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2022/datasette-lite-plugin.jpg" alt="Screenshot showing the results of that SQL query running in Datasette Lite. It compares the string barrack obama with the string barrack h obama and shows various different scores." style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;h4&gt;Plugin compatibility&lt;/h4&gt;
&lt;p&gt;Unfortunately, many existing Datasette plugins aren't yet compatible with Datasette Lite. Most importantly, visualization plugins such as &lt;a href="https://datasette.io/plugins/datasette-cluster-map"&gt;datasette-cluster-map&lt;/a&gt; and &lt;a href="https://datasette.io/plugins/datasette-vega"&gt;datasette-vega&lt;/a&gt; don't work.&lt;/p&gt;
&lt;p&gt;This is because I haven't yet solved the challenge of loading additional JavaScript and CSS into Datasette Lite - see &lt;a href="https://github.com/simonw/datasette-lite/issues/8"&gt;issue #8&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Here's the full list of plugins that I've confirmed work with Datasette Lite so far:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://datasette.io/plugins/datasette-packages"&gt;datasette-packages&lt;/a&gt; - Show a list of currently installed Python packages - &lt;a href="https://lite.datasette.io/?install=datasette-packages#/-/packages"&gt;demo&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://datasette.io/plugins/datasette-dateutil"&gt;datasette-dateutil&lt;/a&gt; - dateutil functions for Datasette - &lt;a href="https://lite.datasette.io/?install=datasette-dateutil#/fixtures?sql=select%0A++dateutil_parse%28%2210+october+2020+3pm%22%29%2C%0A++dateutil_parse_fuzzy%28%22This+is+due+10+september%22%29%2C%0A++dateutil_parse%28%221%2F2%2F2020%22%29%2C%0A++dateutil_parse%28%222020-03-04%22%29%2C%0A++dateutil_parse_dayfirst%28%222020-03-04%22%29%3B"&gt;demo&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://datasette.io/plugins/datasette-schema-versions"&gt;datasette-schema-versions&lt;/a&gt; - Datasette plugin that shows the schema version of every attached database - &lt;a href="https://lite.datasette.io/?install=datasette-schema-versions#/-/schema-versions"&gt;demo&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://datasette.io/plugins/datasette-debug-asgi"&gt;datasette-debug-asgi&lt;/a&gt; - Datasette plugin for dumping out the ASGI scope. - &lt;a href="https://lite.datasette.io/?install=datasette-debug-asgi#/-/asgi-scope"&gt;demo&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://datasette.io/plugins/datasette-query-links"&gt;datasette-query-links&lt;/a&gt; - Turn SELECT queries returned by a query into links to execute them - &lt;a href="https://lite.datasette.io/?install=datasette-query-links#/fixtures?sql=select%0D%0A++'select+*+from+%5Bfacetable%5D'+as+query%0D%0Aunion%0D%0Aselect%0D%0A++'select+sqlite_version()'%0D%0Aunion%0D%0Aselect%0D%0A++'select+this+is+invalid+SQL+so+will+not+be+linked'"&gt;demo&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://datasette.io/plugins/datasette-json-html"&gt;datasette-json-html&lt;/a&gt; - Datasette plugin for rendering HTML based on JSON values - &lt;a href="https://lite.datasette.io/?install=datasette-json-html#/fixtures?sql=select+%27%5B%0A++++%7B%0A++++++++%22href%22%3A+%22https%3A%2F%2Fsimonwillison.net%2F%22%2C%0A++++++++%22label%22%3A+%22Simon+Willison%22%0A++++%7D%2C%0A++++%7B%0A++++++++%22href%22%3A+%22https%3A%2F%2Fgithub.com%2Fsimonw%2Fdatasette%22%2C%0A++++++++%22label%22%3A+%22Datasette%22%0A++++%7D%0A%5D%27+as+output"&gt;demo&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://datasette.io/plugins/datasette-haversine"&gt;datasette-haversine&lt;/a&gt; - Datasette plugin that adds a custom SQL function for haversine distances - &lt;a href="https://lite.datasette.io/?install=datasette-haversine#/fixtures?sql=select+haversine%280%2C+154%2C+1%2C+131%29"&gt;demo&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://datasette.io/plugins/datasette-jellyfish"&gt;datasette-jellyfish&lt;/a&gt; - Datasette plugin that adds custom SQL functions for fuzzy string matching, built on top of the Jellyfish Python library - &lt;a href="https://lite.datasette.io/?install=datasette-jellyfish#/fixtures?sql=SELECT%0A++++levenshtein_distance%28%3As1%2C+%3As2%29%2C%0A++++damerau_levenshtein_distance%28%3As1%2C+%3As2%29%2C%0A++++hamming_distance%28%3As1%2C+%3As2%29%2C%0A++++jaro_similarity%28%3As1%2C+%3As2%29%2C%0A++++jaro_winkler_similarity%28%3As1%2C+%3As2%29%2C%0A++++match_rating_comparison%28%3As1%2C+%3As2%29%3B&amp;amp;s1=barrack+obama&amp;amp;s2=barrack+h+obama"&gt;demo&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://datasette.io/plugins/datasette-pretty-json"&gt;datasette-pretty-json&lt;/a&gt; - Datasette plugin that pretty-prints any column values that are valid JSON objects or arrays. - &lt;a href="https://lite.datasette.io/?install=datasette-pretty-json#/fixtures?sql=select+%27%7B%22this%22%3A+%5B%22is%22%2C+%22nested%22%2C+%22json%22%5D%7D%27"&gt;demo&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://datasette.io/plugins/datasette-yaml"&gt;datasette-yaml&lt;/a&gt; - Export Datasette records as YAML - &lt;a href="https://lite.datasette.io/?install=datasette-yaml#/fixtures/compound_three_primary_keys.yaml"&gt;demo&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://datasette.io/plugins/datasette-copyable"&gt;datasette-copyable&lt;/a&gt; - Datasette plugin for outputting tables in formats suitable for copy and paste - &lt;a href="https://lite.datasette.io/?install=datasette-copyable#/fixtures/compound_three_primary_keys.copyable?_table_format=github"&gt;demo&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;How it works&lt;/h4&gt;
&lt;p&gt;The implementation is pretty simple - it can be seen &lt;a href="https://github.com/simonw/datasette-lite/commit/5e348e84ff2cb38e4749aa36182db78e235dd9bc"&gt;in this commit&lt;/a&gt;. The short version is that &lt;code&gt;?install=&lt;/code&gt; options are passed through to the Python web worker that powers Datasette Lite, which then runs the following:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-k"&gt;for&lt;/span&gt; &lt;span class="pl-s1"&gt;install_url&lt;/span&gt; &lt;span class="pl-c1"&gt;in&lt;/span&gt; &lt;span class="pl-s1"&gt;install_urls&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-en"&gt;install&lt;/span&gt;(&lt;span class="pl-s1"&gt;install_url&lt;/span&gt;)&lt;/pre&gt;
&lt;p&gt;&lt;a href="https://pyodide.org/en/stable/usage/api/micropip-api.html"&gt;micropip&lt;/a&gt; is a component of Pyodide which knows how to install pure Python wheels directly from PyPI into the browser's emulated Python environment. If you open up the browser devtools networking panel you can see that in action!&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2022/datasette-lite-devtools.jpg" alt="The Firefox Network pane shows a flurry of traffic, some of it to PyPI to look up the JSON descriptions of packages followed by downloads of .whl files from files.pythonhosted.org" style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;Since the &lt;code&gt;?install=&lt;/code&gt; parameter is being passed directly to &lt;code&gt;micropip.install()&lt;/code&gt; you don't even need to provide names of packages hosted on PyPI - you could instead provide the URL to a wheel file that you're hosting elsewhere.&lt;/p&gt;
&lt;p&gt;This means you can use &lt;code&gt;?install=&lt;/code&gt; as a code injection attack - you can install any Python code you want into the environent. I think that's fine - the only person who will be affected by this is the user who is viewing the page, and the &lt;code&gt;lite.datasette.io&lt;/code&gt; domain deliberately doesn't have any cookies set that could cause  problems if someone were to steal them in some way.&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/plugins"&gt;plugins&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/pypi"&gt;pypi&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;a href="https://simonwillison.net/tags/cors"&gt;cors&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="plugins"/><category term="projects"/><category term="pypi"/><category term="datasette"/><category term="webassembly"/><category term="pyodide"/><category term="datasette-lite"/><category term="cors"/></entry><entry><title>Weeknotes: Datasette Lite, nogil Python, HYTRADBOI</title><link href="https://simonwillison.net/2022/May/6/weeknotes/#atom-tag" rel="alternate"/><published>2022-05-06T22:56:39+00:00</published><updated>2022-05-06T22:56:39+00:00</updated><id>https://simonwillison.net/2022/May/6/weeknotes/#atom-tag</id><summary type="html">
    &lt;p&gt;My big project this week was &lt;a href="https://simonwillison.net/2022/May/4/datasette-lite/"&gt;Datasette Lite&lt;/a&gt;, a new way to run Datasette directly in a browser, powered by WebAssembly and &lt;a href="https://pyodide.org/"&gt;Pyodide&lt;/a&gt;. I also continued my research into running SQL queries in parallel, described &lt;a href="https://simonwillison.net/2022/Apr/27/parallel-queries/"&gt;last week&lt;/a&gt;. Plus I spoke at &lt;a href="https://www.hytradboi.com/"&gt;HYTRADBOI&lt;/a&gt;.&lt;/p&gt;
&lt;h4&gt;Datasette Lite&lt;/h4&gt;
&lt;p&gt;This started out as a research project, inspired by the excitement around Python in the browser from PyCon US last week (which I didn't attend, but observed with some jealousy on Twitter).&lt;/p&gt;
&lt;p&gt;I've been wanting to explore this possibility for a while. &lt;a href="https://jupyterlite.readthedocs.io/en/latest/"&gt;JupyterLite&lt;/a&gt; had convinced me that it would be feasible to run Datasette using Pyodide, especially after I found out that the &lt;code&gt;sqlite3&lt;/code&gt; module from the Python standard library works there already.&lt;/p&gt;
&lt;p&gt;I have a private "notes" GitHub repository which I use to keep notes in GitHub issues. I started a thread there researching the possibility of running an ASGI application in Pyodide, thinking that might be a good starting point to getting Datasette to work.&lt;/p&gt;
&lt;p&gt;The proof of concept moved remarkably quickly, especially once I realized that Service Workers weren't going to work but Web Workers might.&lt;/p&gt;
&lt;p&gt;Once I had comitted to Datasette Lite as a full project I started &lt;a href="https://github.com/simonw/datasette-lite"&gt;a new repository&lt;/a&gt; for it and transferred across my initial prototype issue thread. You can read that full thread for a blow-by-blow account of how my research pulled together in &lt;a href="https://github.com/simonw/datasette-lite/issues/1"&gt;datasette-lite issue #1&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The rest of the project is documented in detail in &lt;a href="https://simonwillison.net/2022/May/4/datasette-lite/"&gt;my blog post&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Since launching it the biggest change I've made was a change of URL: since it's clearly going to be a core component of the Datasette project going forward I promoted it from &lt;code&gt;simonw.github.io/datasette-lite/&lt;/code&gt; to its new permanent home at &lt;a href="https://lite.datasette.io"&gt;lite.datasette.io&lt;/a&gt;. It's still hosted by GitHub Pages - here's &lt;a href="https://til.simonwillison.net/github/custom-subdomain-github-pages"&gt;my TIL&lt;/a&gt; about setting up the new domain.&lt;/p&gt;
&lt;p&gt;It may have started as a proof of concept tech demo, but the response to it so far has convinced me that I should really take it seriously. Being able to host Datasette without needing to run any server-side code at all is an incredibly compelling experience.&lt;/p&gt;
&lt;p&gt;It doesn't matter how hard I work on getting the Datasette &lt;a href="https://docs.datasette.io/en/stable/publish.html"&gt;deployment experience&lt;/a&gt; as easy as possible, static file hosting will always be an order of magnitude more accessible. And even at this early stage Datasette Lite is already proving to be a genuinely useful way to run the software.&lt;/p&gt;
&lt;p&gt;As part of this research I also shipped &lt;a href="https://sqlite-utils.datasette.io/en/stable/changelog.html#v3-26-1"&gt;sqlite-utils 3.26.1&lt;/a&gt; with a minor dependency fix that means it works in Pyodide now. You can try that out by running the following in the &lt;a href="https://pyodide.org/en/stable/console.html"&gt;Pyodide REPL&lt;/a&gt;:&lt;/p&gt;
&lt;div class="highlight highlight-text-python-console"&gt;&lt;pre&gt;&amp;gt;&amp;gt;&amp;gt; &lt;span class="pl-k"&gt;import&lt;/span&gt; micropip
&amp;gt;&amp;gt;&amp;gt; &lt;span class="pl-k"&gt;await&lt;/span&gt; micropip.install(&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;sqlite-utils&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;)
&amp;gt;&amp;gt;&amp;gt; &lt;span class="pl-k"&gt;import&lt;/span&gt; sqlite_utils
&amp;gt;&amp;gt;&amp;gt; db &lt;span class="pl-k"&gt;=&lt;/span&gt; sqlite_utils.Database(&lt;span class="pl-v"&gt;memory&lt;/span&gt;&lt;span class="pl-k"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;True&lt;/span&gt;)
&amp;gt;&amp;gt;&amp;gt; &lt;span class="pl-c1"&gt;list&lt;/span&gt;(db.query(&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;select 3 * 5&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;))
[{'3 * 5': 15}]&lt;/pre&gt;&lt;/div&gt;
&lt;h4 id="nogil"&gt;Parallel SQL queries work... if you can get rid of the GIL&lt;/h4&gt;
&lt;p&gt;Last week I described my effort to implement &lt;a href="https://simonwillison.net/2022/Apr/27/parallel-queries/"&gt;Parallel SQL queries for Datasette&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The idea there was that many Datasette pages execute multiple SQL queries - a &lt;code&gt;count(*)&lt;/code&gt; and a &lt;code&gt;select ... limit 101&lt;/code&gt; for example - that could be run in parallel instead of serial, for a potential improvement in page load times.&lt;/p&gt;
&lt;p&gt;My hope was that I could get away with this despite Python's infamous Global Interpreter Lock because the &lt;code&gt;sqlite3&lt;/code&gt; C module releases the GIL when it executes a query.&lt;/p&gt;
&lt;p&gt;My initial results weren't showing an increase in performance, even while the queries were shown to be overlapping each other. I opened &lt;a href="https://github.com/simonw/datasette/issues/1727"&gt;a research thread&lt;/a&gt; and spent some time this week investigating.&lt;/p&gt;
&lt;p&gt;My conclusion, sadly, was that the GIL was indeed to blame. &lt;code&gt;sqlite3&lt;/code&gt; releases the GIL to execute the query, but there's still a lot of work that happens in Python land itself - most importantly the code that assembles the objects that represent the rows returned by the query, which is still subject to the GIL.&lt;/p&gt;
&lt;p&gt;Then &lt;a href="https://lobste.rs/s/9hj80j/when_python_can_t_thread_deep_dive_into_gil#c_2n0fga"&gt;this comment&lt;/a&gt; on a thread about the GIL on Lobsters reminded me of the &lt;a href="https://github.com/colesbury/nogil"&gt;nogil fork&lt;/a&gt; of Python by Sam Gross, who has been working on this problem for several years now.&lt;/p&gt;
&lt;p&gt;Since that fork has &lt;a href="https://github.com/colesbury/nogil#docker"&gt;a Docker image&lt;/a&gt; trying it out was easy... and to my amazement &lt;a href="https://simonwillison.net/2022/Apr/29/nogil/"&gt;it worked&lt;/a&gt;! Running my parallel queries implementation against &lt;code&gt;nogil&lt;/code&gt; Python reduced a page load time from 77ms to 47ms.&lt;/p&gt;
&lt;p&gt;Sam's work is against Python 3.9, but he's &lt;a href="https://lukasz.langa.pl/5d044f91-49c1-4170-aed1-62b6763e6ad0/"&gt;discussing options&lt;/a&gt; for bringing his improvemets into Python itself with the core maintainers. I'm hopeful that this might happen in the next few years. It's an incredible piece of work.&lt;/p&gt;
&lt;p&gt;An amusing coincidence: one restriction of WASM and Pyodide is that they can't start new threads - so as part of getting Datasette to work on that platform I had to &lt;a href="https://github.com/simonw/datasette/issues/1735"&gt;add a new setting&lt;/a&gt; that disables the ability to run SQL queries in threads entirely!&lt;/p&gt;
&lt;h4&gt;datasette-copy-to-memory&lt;/h4&gt;
&lt;p&gt;One question I found myself asking while investigating parallel SQL queries (before I determined that the GIL was to blame) was whether parallel SQLite queries against the same database file were suffering from some form of file locking or contention.&lt;/p&gt;
&lt;p&gt;To rule that out, I built a new plugin: &lt;a href="https://datasette.io/plugins/datasette-copy-to-memory"&gt;datasette-copy-to-memory&lt;/a&gt; - which reads a SQLite database from disk and copies it into an in-memory database when Datasette first starts up.&lt;/p&gt;
&lt;p&gt;This didn't make an observable difference in performance, but I've not tested it extensively - especially not against larger databases using servers with increased amounts of available RAM.&lt;/p&gt;
&lt;p&gt;If you're inspired to give this plugin a go I'd love to hear about your results.&lt;/p&gt;
&lt;h4&gt;asgi-gzip and datasette-gzip&lt;/h4&gt;
&lt;p&gt;I mentioned &lt;code&gt;datasette-gzip&lt;/code&gt; last week: a plugin that acts as a wrapper around the excellent &lt;code&gt;GZipMiddleware&lt;/code&gt; from &lt;a href="https://www.starlette.io/"&gt;Starlette&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The performance improvements from this - especially for larger HTML tables, which it turns out compress extremely well - were significant. Enough so that I plan to bring gzip support into Datasette core very shortly.&lt;/p&gt;
&lt;p&gt;Since I don't want to add the whole of Starlette as a dependency just to get gzip support, I extracted that code out into a new Python package called &lt;a href="https://github.com/simonw/asgi-gzip"&gt;asgi-gzip&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The obvious risk with doing this is that it might fall behind the excellent Starlette implementation. So I came up with a pattern based on &lt;a href="https://simonwillison.net/2020/Oct/9/git-scraping/"&gt;Git scraping&lt;/a&gt; that would automatically open a new GitHub issue should the borrowed Starlette code change in the future.&lt;/p&gt;
&lt;p&gt;I wrote about that pattern in &lt;a href="https://simonwillison.net/2022/Apr/28/issue-on-changes/"&gt;Automatically opening issues when tracked file content changes&lt;/a&gt;.&lt;/p&gt;
&lt;h4&gt;Speaking at HYTRADBOI&lt;/h4&gt;
&lt;p&gt;I spoke at the &lt;a href="https://www.hytradboi.com/"&gt;HYTRADBOI conference&lt;/a&gt; last week: Have You Tried Rubbing A Database On It.&lt;/p&gt;
&lt;p&gt;HYTRADBOI was organized by Jamie Brandon. It was a neat event, with a smart format: 34 pre-recorded 10 minute long talks, arranged into a schedule to encourage people to watch and discuss them at specific times during the day of the event.&lt;/p&gt;
&lt;p&gt;It's worth reading Jamie's &lt;a href="https://www.scattered-thoughts.net/writing/hytradboi-2022-postmortem/"&gt;postmortem of the event&lt;/a&gt; for some insightful thinking on online event organization.&lt;/p&gt;
&lt;p&gt;My talk was &lt;a href="https://www.hytradboi.com/2022/datasette-a-big-bag-of-tricks-for-solving-interesting-problems-using-sqlite"&gt;Datasette: a big bag of tricks for solving interesting problems using SQLite&lt;/a&gt;. It ended up working out as a lightning-fast 10 minute tutorial on using the &lt;a href="https://sqlite-utils.datasette.io/en/stable/cli.html"&gt;sqlite-utils CLI&lt;/a&gt; to clean up some data (in this case &lt;a href="https://geodata.myfwc.com/datasets/myfwc::manatee-carcass-recovery-locations-in-florida/about"&gt;Manatee Carcass Recovery Locations in Florida&lt;/a&gt; since 1974) and then using Datasette to explore and publish it.&lt;/p&gt;
&lt;p&gt;I've posted &lt;a href="https://gist.github.com/simonw/c61447d866f7f29d368183fb09d9bf41"&gt;some basic notes&lt;/a&gt; to accompany the talk. My plan is to use this as the basis for an official tutorial on &lt;code&gt;sqlite-utils&lt;/code&gt; for the &lt;a href="https://datasette.io/tutorials"&gt;tutorials section&lt;/a&gt; of the Datasette website.&lt;/p&gt;
&lt;h4&gt;Releases this week&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/simonw/datasette"&gt;datasette&lt;/a&gt;&lt;/strong&gt;: &lt;a href="https://github.com/simonw/datasette/releases/tag/0.62a0"&gt;0.62a0&lt;/a&gt; - (&lt;a href="https://github.com/simonw/datasette/releases"&gt;111 releases total&lt;/a&gt;) - 2022-05-02
&lt;br /&gt;An open source multi-tool for exploring and publishing data&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/simonw/sqlite-utils"&gt;sqlite-utils&lt;/a&gt;&lt;/strong&gt;: &lt;a href="https://github.com/simonw/sqlite-utils/releases/tag/3.26.1"&gt;3.26.1&lt;/a&gt; - (&lt;a href="https://github.com/simonw/sqlite-utils/releases"&gt;100 releases total&lt;/a&gt;) - 2022-05-02
&lt;br /&gt;Python CLI utility and library for manipulating SQLite databases&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/simonw/click-default-group-wheel"&gt;click-default-group-wheel&lt;/a&gt;&lt;/strong&gt;: &lt;a href="https://github.com/simonw/click-default-group-wheel/releases/tag/1.2.2"&gt;1.2.2&lt;/a&gt; - 2022-05-02
&lt;br /&gt;Extends click.Group to invoke a command without explicit subcommand name (this version publishes a wheel)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/simonw/s3-credentials"&gt;s3-credentials&lt;/a&gt;&lt;/strong&gt;: &lt;a href="https://github.com/simonw/s3-credentials/releases/tag/0.11"&gt;0.11&lt;/a&gt; - (&lt;a href="https://github.com/simonw/s3-credentials/releases"&gt;11 releases total&lt;/a&gt;) - 2022-05-01
&lt;br /&gt;A tool for creating credentials for accessing S3 buckets&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/simonw/datasette-copy-to-memory"&gt;datasette-copy-to-memory&lt;/a&gt;&lt;/strong&gt;: &lt;a href="https://github.com/simonw/datasette-copy-to-memory/releases/tag/0.2"&gt;0.2&lt;/a&gt; - (&lt;a href="https://github.com/simonw/datasette-copy-to-memory/releases"&gt;5 releases total&lt;/a&gt;) - 2022-04-30
&lt;br /&gt;Copy database files into an in-memory database on startup&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/simonw/datasette-gzip"&gt;datasette-gzip&lt;/a&gt;&lt;/strong&gt;: &lt;a href="https://github.com/simonw/datasette-gzip/releases/tag/0.2"&gt;0.2&lt;/a&gt; - (&lt;a href="https://github.com/simonw/datasette-gzip/releases"&gt;2 releases total&lt;/a&gt;) - 2022-04-28
&lt;br /&gt;Add gzip compression to Datasette&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/simonw/asgi-gzip"&gt;asgi-gzip&lt;/a&gt;&lt;/strong&gt;: &lt;a href="https://github.com/simonw/asgi-gzip/releases/tag/0.1"&gt;0.1&lt;/a&gt; - 2022-04-28
&lt;br /&gt;gzip middleware for ASGI applications, extracted from Starlette&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;TIL this week&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://til.simonwillison.net/service-workers/intercept-fetch"&gt;Intercepting fetch in a service worker&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://til.simonwillison.net/github/custom-subdomain-github-pages"&gt;Setting up a custom subdomain for a GitHub Pages site&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/gil"&gt;gil&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/speaking"&gt;speaking&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/weeknotes"&gt;weeknotes&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="gil"/><category term="projects"/><category term="python"/><category term="speaking"/><category term="datasette"/><category term="asgi"/><category term="webassembly"/><category term="weeknotes"/><category term="pyodide"/><category term="datasette-lite"/></entry><entry><title>Datasette Lite: a server-side Python web application running in a browser</title><link href="https://simonwillison.net/2022/May/4/datasette-lite/#atom-tag" rel="alternate"/><published>2022-05-04T15:16:49+00:00</published><updated>2022-05-04T15:16:49+00:00</updated><id>https://simonwillison.net/2022/May/4/datasette-lite/#atom-tag</id><summary type="html">
    &lt;p&gt;&lt;a href="https://github.com/simonw/datasette-lite"&gt;Datasette Lite&lt;/a&gt; is a new way to run &lt;a href="https://datasette.io/"&gt;Datasette&lt;/a&gt;: entirely in a browser, taking advantage of the incredible &lt;a href="https://pyodide.org/"&gt;Pyodide&lt;/a&gt; project which provides Python compiled to WebAssembly plus a whole suite of useful extras.&lt;/p&gt;
&lt;p&gt;You can try it out here:&lt;/p&gt;
&lt;p&gt;&lt;a href="https://lite.datasette.io/"&gt;https://lite.datasette.io/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2022/datasette-lite.jpg" alt="A screenshot of the pypi_packages database table running in Google Chrome in a page with the URL of lite.datasette.io/#/content/pypi_packages?_facet=author" style="max-width:100%;" /&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;&lt;strong&gt;Update 20th June 2022&lt;/strong&gt;: Datasette Lite can now load CSV files directly by URL, see &lt;a href="https://simonwillison.net/2022/Jun/20/datasette-lite-csvs/"&gt;Joining CSV files in your browser using Datasette Lite&lt;/a&gt; for details.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;&lt;strong&gt;Update 17th August 2022&lt;/strong&gt;: It can now &lt;a href="https://simonwillison.net/2022/Aug/17/datasette-lite-plugins/"&gt;load Datasette plugins as well&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;The initial example loads two databases - the classic &lt;a href="https://latest.datasette.io/fixtures"&gt;fixtures.db&lt;/a&gt; used by the Datasette test suite, and the &lt;a href="https://datasette.io/content"&gt;content.db&lt;/a&gt; database that powers the official &lt;a href="https://datasette.io/"&gt;datasette.io&lt;/a&gt; website (described in some detail in &lt;a href="https://simonwillison.net/2021/Jul/28/baked-data/"&gt;my post about Baked Data&lt;/a&gt;).&lt;/p&gt;
&lt;p&gt;You can instead use the "Load database by URL to a SQLite DB" button to paste in a URL to your own database. That file will need to be served with CORS headers that allow it to be fetched by the website (&lt;a href="https://github.com/simonw/datasette-lite/#opening-other-databases"&gt;see README&lt;/a&gt;).&lt;/p&gt;
&lt;p&gt;Try this URL, for example:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;https://congress-legislators.datasettes.com/legislators.db
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You can &lt;a href="https://lite.datasette.io/?url=https%3A%2F%2Fcongress-legislators.datasettes.com%2Flegislators.db"&gt;follow this link&lt;/a&gt; to open that database in Datasette Lite.&lt;/p&gt;
&lt;p&gt;Datasette Lite supports almost all of Datasette's regular functionality: you can view tables, apply facets, run your own custom SQL results and export the results as CSV or JSON.&lt;/p&gt;
&lt;p&gt;It's basically the full Datasette experience, except it's running entirely in your browser with no server (other than the static file hosting provided here by GitHub Pages) required.&lt;/p&gt;
&lt;p&gt;I’m pretty stunned that this is possible now.&lt;/p&gt;
&lt;p&gt;I had to make some small changes to Datasette to get this to work, detailed below, but really nothing extravagant - the demo is running the exact same Python code as the regular server-side Datasette application, just inside a web worker process in a browser rather than on a server.&lt;/p&gt;
&lt;p&gt;The implementation is pretty small - around 300 lines of JavaScript. You can see the code in the &lt;a href="https://github.com/simonw/datasette-lite"&gt;simonw/datasette-lite&lt;/a&gt; repository - in two files, &lt;a href="https://github.com/simonw/datasette-lite/blob/6ae4cacf140f0c7c6eafa8cf0f92a2dae44425ff/index.html"&gt;index.html&lt;/a&gt; and &lt;a href="https://github.com/simonw/datasette-lite/blob/main/webworker.js"&gt;webworker.js&lt;/a&gt;&lt;/p&gt;
&lt;h4&gt;Why build this?&lt;/h4&gt;
&lt;p&gt;I built this because I want as many people as possible to be able to use my software.&lt;/p&gt;
&lt;p&gt;I've invested a ton of effort in reducing the friction to getting started with Datasette. I've &lt;a href="https://docs.datasette.io/en/stable/installation.html"&gt;documented the install process&lt;/a&gt;, I've &lt;a href="https://formulae.brew.sh/formula/datasette"&gt;packaged it for Homebrew&lt;/a&gt;, I've written guides to &lt;a href="https://docs.datasette.io/en/stable/getting_started.html#try-datasette-without-installing-anything-using-glitch"&gt;running it on Glitch&lt;/a&gt;, I've built tools to help deploy it to &lt;a href="https://docs.datasette.io/en/stable/publish.html#publishing-to-heroku"&gt;Heroku&lt;/a&gt;, &lt;a href="https://docs.datasette.io/en/stable/publish.html#publishing-to-google-cloud-run"&gt;Cloud Run&lt;/a&gt;, &lt;a href="https://docs.datasette.io/en/stable/publish.html#publishing-to-vercel"&gt;Vercel&lt;/a&gt; and &lt;a href="https://simonwillison.net/2022/Feb/15/fly-volumes/"&gt;Fly.io&lt;/a&gt;. I even taught myself Electron and built a macOS &lt;a href="https://datasette.io/desktop"&gt;Datasette Desktop&lt;/a&gt; application, so people could install it without having to think about their Python environment.&lt;/p&gt;
&lt;p&gt;Datasette Lite is my latest attempt at this. Anyone with a browser that can run WebAssembly can now run Datasette in it - if they can afford the 10MB load (which in many places with metered internet access is way too much).&lt;/p&gt;
&lt;p&gt;I also built this because I'm fascinated by WebAssembly and I've been looking for an opportunity to really try it out.&lt;/p&gt;
&lt;p&gt;And, I find this project deeply amusing. Running a Python server-side web application in a browser still feels like an absurd thing to do. I love that it works.&lt;/p&gt;
&lt;p&gt;I'm deeply inspired by &lt;a href="https://jupyterlite.readthedocs.io/en/latest/"&gt;JupyterLite&lt;/a&gt;. Datasette Lite's name is a tribute to that project.&lt;/p&gt;
&lt;h4&gt;How it works: Python in a Web Worker&lt;/h4&gt;
&lt;p&gt;Datasette Lite does most of its work in a &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers"&gt;Web Worker&lt;/a&gt; - a separate process that can run expensive CPU operations (like an entire Python interpreter) without blocking the main browser's UI thread.&lt;/p&gt;
&lt;p&gt;The worker starts running when you load the page. It loads a WebAssembly compiled Python interpreter from a CDN, then installs Datasette and its dependencies into that interpreter using &lt;a href="https://pyodide.org/en/stable/usage/loading-packages.html#micropip"&gt;micropip&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;It also downloads the specified SQLite database files using the browser's HTTP fetching mechanism and writes them to a virtual in-memory filesystem managed by Pyodide.&lt;/p&gt;
&lt;p&gt;Once everything is installed, it imports &lt;code&gt;datasette&lt;/code&gt; and creates a &lt;code&gt;Datasette()&lt;/code&gt; object called &lt;code&gt;ds&lt;/code&gt;. This object stays resident in the web worker.&lt;/p&gt;
&lt;p&gt;To render pages, the &lt;code&gt;index.html&lt;/code&gt; page sends a message to the web worker specifying which Datasette path has been requested - &lt;code&gt;/&lt;/code&gt; for the homepage, &lt;code&gt;/fixtures&lt;/code&gt; for the database index page, &lt;code&gt;/fixtures/facetable&lt;/code&gt; for a table page and so on.&lt;/p&gt;
&lt;p&gt;The web worker then simulates an HTTP GET against that path within Datasette using the following code:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-s1"&gt;response&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;ds&lt;/span&gt;.&lt;span class="pl-s1"&gt;client&lt;/span&gt;.&lt;span class="pl-en"&gt;get&lt;/span&gt;(&lt;span class="pl-s1"&gt;path&lt;/span&gt;, &lt;span class="pl-s1"&gt;follow_redirects&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;True&lt;/span&gt;)&lt;/pre&gt;
&lt;p&gt;This takes advantage of a really useful internal Datasette API: &lt;a href="https://docs.datasette.io/en/stable/internals.html#datasette-client"&gt;datasette.client&lt;/a&gt; is an &lt;a href="https://www.python-httpx.org/"&gt;HTTPX&lt;/a&gt; client object that can be used to execute HTTP requests against Datasette internally, without doing a round-trip across the network.&lt;/p&gt;
&lt;p&gt;I initially added &lt;code&gt;datasette.client&lt;/code&gt; with the goal of making any JSON APIs that Datasette provides available for internal calls by plugins as well, and to make it easier to write automated tests. It turns out to have other interesting applications too!&lt;/p&gt;
&lt;p&gt;The web worker sends a message back to &lt;code&gt;index.html&lt;/code&gt; with the status code, content type and content retrieved from Datasette. JavaScript in &lt;code&gt;index.html&lt;/code&gt; then injects that HTML into the page using &lt;code&gt;.innerHTML&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;To get internal links working, Datasette Lite uses a trick I originally learned from jQuery: it applies a capturing event listener to the area of the page displaying the content, such that any link clicks or form submissions will be intercepted by a JavaScript function. That JavaScript can then turn them into new messages to the web worker rather than navigating to another page.&lt;/p&gt;
&lt;h4&gt;Some annotated code&lt;/h4&gt;
&lt;p&gt;Here are annotated versions of the most important pieces of code. In &lt;code&gt;index.html&lt;/code&gt; this code manages the worker and updates the page when it recieves messages from it:&lt;/p&gt;
&lt;div class="highlight highlight-source-js"&gt;&lt;pre&gt;&lt;span class="pl-c"&gt;// Load the worker script&lt;/span&gt;
&lt;span class="pl-k"&gt;const&lt;/span&gt; &lt;span class="pl-s1"&gt;datasetteWorker&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-k"&gt;new&lt;/span&gt; &lt;span class="pl-v"&gt;Worker&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;"webworker.js"&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-c"&gt;// Extract the ?url= from the current page's URL&lt;/span&gt;
&lt;span class="pl-k"&gt;const&lt;/span&gt; &lt;span class="pl-s1"&gt;initialUrl&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-k"&gt;new&lt;/span&gt; &lt;span class="pl-v"&gt;URLSearchParams&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;location&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;search&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-en"&gt;get&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;'url'&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-c"&gt;// Message that to the worker: {type: 'startup', initialUrl: url}&lt;/span&gt;
&lt;span class="pl-s1"&gt;datasetteWorker&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;postMessage&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;type&lt;/span&gt;: &lt;span class="pl-s"&gt;'startup'&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; initialUrl&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-c"&gt;// This function does most of the work - it responds to messages sent&lt;/span&gt;
&lt;span class="pl-c"&gt;// back from the worker to the index page:&lt;/span&gt;
&lt;span class="pl-s1"&gt;datasetteWorker&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;onmessage&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;event&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-c1"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
  &lt;span class="pl-c"&gt;// {type: log, line: ...} messages are appended to a log textarea:&lt;/span&gt;
  &lt;span class="pl-k"&gt;var&lt;/span&gt; &lt;span class="pl-s1"&gt;ta&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-smi"&gt;document&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;getElementById&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;'loading-logs'&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;event&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;data&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;type&lt;/span&gt; &lt;span class="pl-c1"&gt;==&lt;/span&gt; &lt;span class="pl-s"&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-s1"&gt;loadingLogs&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;push&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;event&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;data&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;line&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;ta&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;value&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;loadingLogs&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;join&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;"\n"&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;ta&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;scrollTop&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;ta&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;scrollHeight&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
    &lt;span class="pl-k"&gt;return&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;html&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-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-c"&gt;// If it's an {error: ...} message show it in a &amp;lt;pre&amp;gt; in a &amp;lt;div&amp;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;event&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;data&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;error&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;html&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s"&gt;`&amp;lt;div style="padding: 0.5em"&amp;gt;&amp;lt;h3&amp;gt;Error&amp;lt;/h3&amp;gt;&amp;lt;pre&amp;gt;&lt;span class="pl-s1"&gt;&lt;span class="pl-kos"&gt;${&lt;/span&gt;&lt;span class="pl-en"&gt;escapeHtml&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;event&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;data&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;error&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;/span&gt;&amp;lt;/pre&amp;gt;&amp;lt;/div&amp;gt;`&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-c"&gt;// If contentType is text/html, show it as straight HTML&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-k"&gt;if&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-pds"&gt;&lt;span class="pl-c1"&gt;/&lt;/span&gt;&lt;span class="pl-cce"&gt;^&lt;/span&gt;text&lt;span class="pl-cce"&gt;\/&lt;/span&gt;html&lt;span class="pl-c1"&gt;/&lt;/span&gt;&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;exec&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;event&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;data&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;contentType&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-s1"&gt;html&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;event&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;data&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;text&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-c"&gt;// For contentType of application/json parse and pretty-print it&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-k"&gt;if&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-pds"&gt;&lt;span class="pl-c1"&gt;/&lt;/span&gt;&lt;span class="pl-cce"&gt;^&lt;/span&gt;application&lt;span class="pl-cce"&gt;\/&lt;/span&gt;json&lt;span class="pl-c1"&gt;/&lt;/span&gt;&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;exec&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;event&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;data&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;contentType&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-s1"&gt;html&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s"&gt;`&amp;lt;pre style="padding: 0.5em"&amp;gt;&lt;span class="pl-s1"&gt;&lt;span class="pl-kos"&gt;${&lt;/span&gt;&lt;span class="pl-en"&gt;escapeHtml&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-c1"&gt;JSON&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;stringify&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-c1"&gt;JSON&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;parse&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;event&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;data&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;text&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;null&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-c1"&gt;4&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&gt;&amp;lt;/pre&amp;gt;`&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-c"&gt;// Anything else (likely CSV data) escape it and show in a &amp;lt;pre&amp;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;html&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s"&gt;`&amp;lt;pre style="padding: 0.5em"&amp;gt;&lt;span class="pl-s1"&gt;&lt;span class="pl-kos"&gt;${&lt;/span&gt;&lt;span class="pl-en"&gt;escapeHtml&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;event&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;data&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;text&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;/span&gt;&amp;lt;/pre&amp;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-c"&gt;// Add the result to &amp;lt;div id="output"&amp;gt; using innerHTML&lt;/span&gt;
  &lt;span class="pl-smi"&gt;document&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;getElementById&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;"output"&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;innerHTML&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;html&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-c"&gt;// Update the document.title if a &amp;lt;title&amp;gt; element is present&lt;/span&gt;
  &lt;span class="pl-k"&gt;let&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-smi"&gt;document&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;getElementById&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;"output"&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-en"&gt;querySelector&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;"title"&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;title&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-smi"&gt;document&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;title&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;title&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;innerText&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-c"&gt;// Scroll to the top of the page after each new page is loaded&lt;/span&gt;
  &lt;span class="pl-smi"&gt;window&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;scrollTo&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;top&lt;/span&gt;: &lt;span class="pl-c1"&gt;0&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-c1"&gt;left&lt;/span&gt;: &lt;span class="pl-c1"&gt;0&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-c"&gt;// If we're showing the initial loading indicator, hide it&lt;/span&gt;
  &lt;span class="pl-smi"&gt;document&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;getElementById&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;'loading-indicator'&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;style&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;display&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s"&gt;'none'&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;/pre&gt;&lt;/div&gt;
&lt;p&gt;The &lt;code&gt;webworker.js&lt;/code&gt; script is where the real magic happens:&lt;/p&gt;
&lt;div class="highlight highlight-source-js"&gt;&lt;pre&gt;&lt;span class="pl-c"&gt;// Load Pyodide from the CDN&lt;/span&gt;
&lt;span class="pl-en"&gt;importScripts&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;"https://cdn.jsdelivr.net/pyodide/dev/full/pyodide.js"&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-c"&gt;// Deliver log messages back to the index.html page&lt;/span&gt;
&lt;span class="pl-k"&gt;function&lt;/span&gt; &lt;span class="pl-en"&gt;log&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;line&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;self&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;postMessage&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;type&lt;/span&gt;: &lt;span class="pl-s"&gt;'log'&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-c1"&gt;line&lt;/span&gt;: &lt;span class="pl-s1"&gt;line&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-kos"&gt;}&lt;/span&gt;

&lt;span class="pl-c"&gt;// This function initializes Pyodide and installs Datasette&lt;/span&gt;
&lt;span class="pl-k"&gt;async&lt;/span&gt; &lt;span class="pl-k"&gt;function&lt;/span&gt; &lt;span class="pl-en"&gt;startDatasette&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;initialUrl&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-c"&gt;// Mechanism for downloading and saving specified DB files&lt;/span&gt;
  &lt;span class="pl-k"&gt;let&lt;/span&gt; &lt;span class="pl-s1"&gt;toLoad&lt;/span&gt; &lt;span class="pl-c1"&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;if&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;initialUrl&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;name&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;initialUrl&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;split&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;'.db'&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;0&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-en"&gt;split&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-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;slice&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-c1"&gt;-&lt;/span&gt;&lt;span class="pl-c1"&gt;1&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;0&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;toLoad&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;push&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;name&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-s1"&gt;initialUrl&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-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-c"&gt;// If no ?url= provided, loads these two demo databases instead:&lt;/span&gt;
    &lt;span class="pl-s1"&gt;toLoad&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;push&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-s"&gt;"fixtures.db"&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-s"&gt;"https://latest.datasette.io/fixtures.db"&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-s1"&gt;toLoad&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;push&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-s"&gt;"content.db"&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-s"&gt;"https://datasette.io/content.db"&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-kos"&gt;}&lt;/span&gt;
  &lt;span class="pl-c"&gt;// This does a LOT of work - it pulls down the WASM blob and starts it running&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-c1"&gt;=&lt;/span&gt; &lt;span class="pl-k"&gt;await&lt;/span&gt; &lt;span class="pl-en"&gt;loadPyodide&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;indexURL&lt;/span&gt;: &lt;span class="pl-s"&gt;"https://cdn.jsdelivr.net/pyodide/dev/full/"&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-c"&gt;// We need these packages for the next bit of code to work&lt;/span&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-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-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;'ssl'&lt;/span&gt;&lt;span class="pl-kos"&gt;,&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-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;'setuptools'&lt;/span&gt;&lt;span class="pl-kos"&gt;,&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-c"&gt;// For pkg_resources&lt;/span&gt;
  &lt;span class="pl-k"&gt;try&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
    &lt;span class="pl-c"&gt;// Now we switch to Python code&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;    # Here's where we download and save those .db files - they are saved&lt;/span&gt;
&lt;span class="pl-s"&gt;    # to a virtual in-memory filesystem provided by Pyodide&lt;/span&gt;
&lt;span class="pl-s"&gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;    # pyfetch is a wrapper around the JS fetch() function - calls using&lt;/span&gt;
&lt;span class="pl-s"&gt;    # it are handled by the browser's regular HTTP fetching mechanism&lt;/span&gt;
&lt;span class="pl-s"&gt;    from pyodide.http import pyfetch&lt;/span&gt;
&lt;span class="pl-s"&gt;    names = []&lt;/span&gt;
&lt;span class="pl-s"&gt;    for name, url in &lt;span class="pl-s1"&gt;&lt;span class="pl-kos"&gt;${&lt;/span&gt;&lt;span class="pl-c1"&gt;JSON&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;stringify&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;toLoad&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;/span&gt;:&lt;/span&gt;
&lt;span class="pl-s"&gt;        response = await pyfetch(url)&lt;/span&gt;
&lt;span class="pl-s"&gt;        with open(name, "wb") as fp:&lt;/span&gt;
&lt;span class="pl-s"&gt;            fp.write(await response.bytes())&lt;/span&gt;
&lt;span class="pl-s"&gt;        names.append(name)&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;    # Workaround for Requested 'h11&amp;lt;0.13,&amp;gt;=0.11', but h11==0.13.0 is already installed&lt;/span&gt;
&lt;span class="pl-s"&gt;    await micropip.install("h11==0.12.0")&lt;/span&gt;
&lt;span class="pl-s"&gt;    # Install Datasette itself!&lt;/span&gt;
&lt;span class="pl-s"&gt;    await micropip.install("datasette==0.62a0")&lt;/span&gt;
&lt;span class="pl-s"&gt;    # Now we can create a Datasette() object that can respond to fake requests&lt;/span&gt;
&lt;span class="pl-s"&gt;    from datasette.app import Datasette&lt;/span&gt;
&lt;span class="pl-s"&gt;    ds = Datasette(names, settings={&lt;/span&gt;
&lt;span class="pl-s"&gt;        "num_sql_threads": 0,&lt;/span&gt;
&lt;span class="pl-s"&gt;    }, metadata = {&lt;/span&gt;
&lt;span class="pl-s"&gt;        # This metadata is displayed in Datasette's footer&lt;/span&gt;
&lt;span class="pl-s"&gt;        "about": "Datasette Lite",&lt;/span&gt;
&lt;span class="pl-s"&gt;        "about_url": "https://github.com/simonw/datasette-lite"&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-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
    &lt;span class="pl-en"&gt;datasetteLiteReady&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-kos"&gt;}&lt;/span&gt; &lt;span class="pl-k"&gt;catch&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;error&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;self&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;postMessage&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;error&lt;/span&gt;: &lt;span class="pl-s1"&gt;error&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;message&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-kos"&gt;}&lt;/span&gt;
&lt;span class="pl-kos"&gt;}&lt;/span&gt;

&lt;span class="pl-c"&gt;// Outside promise pattern&lt;/span&gt;
&lt;span class="pl-c"&gt;// https://github.com/simonw/datasette-lite/issues/25#issuecomment-1116948381&lt;/span&gt;
&lt;span class="pl-k"&gt;let&lt;/span&gt; &lt;span class="pl-s1"&gt;datasetteLiteReady&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;readyPromise&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-k"&gt;new&lt;/span&gt; &lt;span class="pl-v"&gt;Promise&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-k"&gt;function&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;resolve&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;datasetteLiteReady&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;resolve&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-kos"&gt;;&lt;/span&gt;

&lt;span class="pl-c"&gt;// This function handles messages sent from index.html to webworker.js&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-en"&gt;onmessage&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-k"&gt;async&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;event&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-c1"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
  &lt;span class="pl-c"&gt;// The first message should be that startup message, carrying the URL&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;event&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;data&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;type&lt;/span&gt; &lt;span class="pl-c1"&gt;==&lt;/span&gt; &lt;span class="pl-s"&gt;'startup'&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-en"&gt;startDatasette&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;event&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;data&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;initialUrl&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;return&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-c"&gt;// This promise trick ensures that we don't run the next block until we&lt;/span&gt;
  &lt;span class="pl-c"&gt;// are certain that startDatasette() has finished and the ds.client&lt;/span&gt;
  &lt;span class="pl-c"&gt;// Python object is ready to use&lt;/span&gt;
  &lt;span class="pl-k"&gt;await&lt;/span&gt; &lt;span class="pl-s1"&gt;readyPromise&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-c"&gt;// Run the reuest in Python to get a status code, content type and text&lt;/span&gt;
  &lt;span class="pl-k"&gt;try&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-kos"&gt;[&lt;/span&gt;&lt;span class="pl-s1"&gt;status&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-s1"&gt;contentType&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-s1"&gt;text&lt;/span&gt;&lt;span class="pl-kos"&gt;]&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;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 json&lt;/span&gt;
&lt;span class="pl-s"&gt;      # ds.client.get(path) simulates running a request through Datasette&lt;/span&gt;
&lt;span class="pl-s"&gt;      response = await ds.client.get(&lt;/span&gt;
&lt;span class="pl-s"&gt;          # Using json here is a quick way to generate a quoted string&lt;/span&gt;
&lt;span class="pl-s"&gt;          &lt;span class="pl-s1"&gt;&lt;span class="pl-kos"&gt;${&lt;/span&gt;&lt;span class="pl-c1"&gt;JSON&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;stringify&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;event&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;data&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;path&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;/span&gt;,&lt;/span&gt;
&lt;span class="pl-s"&gt;          # If Datasette redirects to another page we want to follow that&lt;/span&gt;
&lt;span class="pl-s"&gt;          follow_redirects=True&lt;/span&gt;
&lt;span class="pl-s"&gt;      )&lt;/span&gt;
&lt;span class="pl-s"&gt;      [response.status_code, response.headers.get("content-type"), response.text]&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;span class="pl-c"&gt;// Message the results back to index.html&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-en"&gt;postMessage&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;{&lt;/span&gt;status&lt;span class="pl-kos"&gt;,&lt;/span&gt; contentType&lt;span class="pl-kos"&gt;,&lt;/span&gt; text&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-kos"&gt;}&lt;/span&gt; &lt;span class="pl-k"&gt;catch&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;error&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-c"&gt;// If an error occurred, send that back as a {error: ...} message&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-en"&gt;postMessage&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;error&lt;/span&gt;: &lt;span class="pl-s1"&gt;error&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;message&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-kos"&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;One last bit of code: here's the JavaScript in &lt;code&gt;index.html&lt;/code&gt; which intercepts clicks on links and turns them into messages to the worker:&lt;/p&gt;
&lt;div class="highlight highlight-source-js"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;let&lt;/span&gt; &lt;span class="pl-s1"&gt;output&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-smi"&gt;document&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;getElementById&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;'output'&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-c"&gt;// This captures any click on any element within &amp;lt;div id="output"&amp;gt;&lt;/span&gt;
&lt;span class="pl-s1"&gt;output&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;addEventListener&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;'click'&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;ev&lt;/span&gt; &lt;span class="pl-c1"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
  &lt;span class="pl-c"&gt;// .closest("a") traverses up the DOM to find if this is an a&lt;/span&gt;
  &lt;span class="pl-c"&gt;// or an element nested in an a. We ignore other clicks.&lt;/span&gt;
  &lt;span class="pl-k"&gt;var&lt;/span&gt; &lt;span class="pl-s1"&gt;link&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;ev&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;srcElement&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;closest&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;"a"&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;link&lt;/span&gt; &lt;span class="pl-c1"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="pl-s1"&gt;link&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;href&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-c"&gt;// It was a click on a &amp;lt;a href="..."&amp;gt; link! Cancel the event:&lt;/span&gt;
    &lt;span class="pl-s1"&gt;ev&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;stopPropagation&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-s1"&gt;ev&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;preventDefault&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-c"&gt;// I want #fragment links to still work, using scrollIntoView()&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-en"&gt;isFragmentLink&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;link&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;href&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-c"&gt;// Jump them to that element, but don't update the URL bar&lt;/span&gt;
      &lt;span class="pl-c"&gt;// since we use # in the URL to mean something else&lt;/span&gt;
      &lt;span class="pl-k"&gt;let&lt;/span&gt; &lt;span class="pl-s1"&gt;fragment&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-k"&gt;new&lt;/span&gt; &lt;span class="pl-c1"&gt;URL&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;link&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;href&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;hash&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;replace&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-kos"&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;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;fragment&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;el&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-smi"&gt;document&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;getElementById&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;fragment&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;el&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;scrollIntoView&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-kos"&gt;}&lt;/span&gt;
      &lt;span class="pl-k"&gt;return&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;href&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;link&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;getAttribute&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;"href"&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-c"&gt;// Links to external sites should open in a new window&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-en"&gt;isExternal&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;href&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-smi"&gt;window&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;open&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;href&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;return&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-c"&gt;// It's an internal link navigation - send it to the worker&lt;/span&gt;
    &lt;span class="pl-en"&gt;loadPath&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;href&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-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-c1"&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;function&lt;/span&gt; &lt;span class="pl-en"&gt;loadPath&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;path&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-c"&gt;// We don't want anything after #, and we only want the /path&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-s1"&gt;path&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;split&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-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;[&lt;/span&gt;&lt;span class="pl-c1"&gt;0&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-en"&gt;replace&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;"http://localhost"&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-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-c"&gt;// Update the URL with the new # location&lt;/span&gt;
  &lt;span class="pl-s1"&gt;history&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;pushState&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;path&lt;/span&gt;: &lt;span class="pl-s1"&gt;path&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;path&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-c1"&gt;+&lt;/span&gt; &lt;span class="pl-s1"&gt;path&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-c"&gt;// Plausible analytics, see:&lt;/span&gt;
  &lt;span class="pl-c"&gt;// https://github.com/simonw/datasette-lite/issues/22&lt;/span&gt;
  &lt;span class="pl-s1"&gt;useAnalytics&lt;/span&gt; &lt;span class="pl-c1"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="pl-en"&gt;plausible&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;'pageview'&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;u&lt;/span&gt;: &lt;span class="pl-s1"&gt;location&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;href&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;replace&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;'?url='&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-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;replace&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-kos"&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;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-c"&gt;// Send a {path: "/path"} message to the worker&lt;/span&gt;
  &lt;span class="pl-s1"&gt;datasetteWorker&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;postMessage&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;{&lt;/span&gt;path&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-kos"&gt;}&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;h4&gt;Getting Datasette to work in Pyodide&lt;/h4&gt;
&lt;p&gt;&lt;a href="https://pyodide.org/"&gt;Pyodide&lt;/a&gt; is the secret sauce that makes this all possible. That project provides several key components:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A custom WebAssembly build of the core Python interpreter, bundling the standard library (including a compiled WASM version of SQLite)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://pyodide.org/en/stable/usage/loading-packages.html#micropip"&gt;micropip&lt;/a&gt; - a package that can install additional Python dependencies by downloading them from &lt;a href="https://pypi.org/"&gt;PyPI&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;A comprehensive JavaScript to Python bridge, including mechanisms for &lt;a href="https://pyodide.org/en/stable/usage/type-conversions.html"&gt;translating Python objects&lt;/a&gt; to JavaScript and vice-versa&lt;/li&gt;
&lt;li&gt;A &lt;a href="https://pyodide.org/en/stable/usage/api/js-api.html"&gt;JavaScript API&lt;/a&gt; for launching and then managing a Python interpreter process&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I found the documentation on &lt;a href="https://pyodide.org/en/stable/usage/webworker.html"&gt;Using Pyodide in a web worker&lt;/a&gt; particularly helpful.&lt;/p&gt;
&lt;p&gt;I had to make a few changes to Datasette to get it working with Pyodide. My &lt;a href="https://github.com/simonw/datasette/issues/1733"&gt;tracking issue for that&lt;/a&gt; has the full details, but the short version is:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Ensure each of Datasette's dependencies had a wheel package on PyPI (as opposed to just a &lt;code&gt;.tar.gz&lt;/code&gt;) - &lt;code&gt;micropip&lt;/code&gt; only works with wheels. I ended up removing &lt;code&gt;python-baseconv&lt;/code&gt; as a dependency and replacing &lt;code&gt;click-default-group&lt;/code&gt; with my own &lt;code&gt;click-default-group-wheel&lt;/code&gt; forked package (&lt;a href="https://github.com/simonw/click-default-group-wheel"&gt;repo here&lt;/a&gt;). I got &lt;code&gt;sqlite-utils&lt;/code&gt; working in Pyodide with this change too, see the &lt;a href="https://sqlite-utils.datasette.io/en/stable/changelog.html#v3-26-1"&gt;3.26.1 release notes&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Work around an error caused by importing &lt;code&gt;uvicorn&lt;/code&gt;. Since Datasette Lite doesn't actually run its own web server that dependency wasn't necessary, so I changed my code to catch the &lt;code&gt;ImportError&lt;/code&gt; in the right place.&lt;/li&gt;
&lt;li&gt;The biggest change: WebAssembly can't run threads, which means Python can't run threads, which means any attempts to start a thread in Python cause an error. Datasette only uses threads in one place: to execute SQL queries in a thread pool where they won't block the event loop. I added a new &lt;code&gt;--setting num_sql_threads 0&lt;/code&gt; feature for disabling threading entirely, see &lt;a href="https://github.com/simonw/datasette/issues/1735"&gt;issue 1735&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Having made those changes I shipped them in a &lt;a href="https://github.com/simonw/datasette/releases/tag/0.62a0"&gt;Datasette 0.62a0&lt;/a&gt; release. It's this release that Datasette Lite installs from PyPI.&lt;/p&gt;
&lt;h4&gt;Fragment hashes for navigation&lt;/h4&gt;
&lt;p&gt;You may have noticed that as you navigate through Datasette Lite the URL bar updates with URLs that look like the following:&lt;/p&gt;
&lt;p&gt;&lt;a href="https://lite.datasette.io/#/content/pypi_packages?_facet=author"&gt;https://lite.datasette.io/#/content/pypi_packages?_facet=author&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;I'm using the &lt;code&gt;#&lt;/code&gt; here to separate out the path within the virtual Datasette instance from the URL to the Datasette Lite application itself.&lt;/p&gt;
&lt;p&gt;Maintaining the state in the URL like this means that the Back and Forward browser buttons work, and also means that users can bookmark pages within the application and share links to them.&lt;/p&gt;
&lt;p&gt;I usually like to avoid &lt;code&gt;#&lt;/code&gt; URLs - the HTML history API makes it possible to use "real" URLs these days, even for JavaScript applications. But in the case of Datasette Lite those URLs wouldn't actually work - if someone attempted to refresh the page or navigate to a link GitHub Pages wouldn't know what file to serve.&lt;/p&gt;
&lt;p&gt;I could run this on my own domain with a catch-all page handler that serves the Datasette Lite HTML and JavaScript no matter what path is requested, but I wanted to keep this as pure and simple as possible.&lt;/p&gt;
&lt;p&gt;This also means I can reserve Datasette Lite's own query string for things like specifying the database to load, and potentially other options in the future.&lt;/p&gt;
&lt;h4&gt;Web Workers or Service Workers?&lt;/h4&gt;
&lt;p&gt;My initial idea for this project was to build it with &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API/Using_Service_Workers"&gt;Service Workers&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Service Workers are some deep, deep browser magic: they let you install a process that can intercept browser traffic to a specific domain (or path within that domain) and run custom code to return a result. Effectively they let you run your own server-side code in the browser itself.&lt;/p&gt;
&lt;p&gt;They're mainly designed for building offline applications, but my hope was that I could use them to offer a full simulation of a server-side application instead.&lt;/p&gt;
&lt;p&gt;Here's my TIL on &lt;a href="https://til.simonwillison.net/service-workers/intercept-fetch"&gt;Intercepting fetch in a service worker&lt;/a&gt; that came out of my initial research.&lt;/p&gt;
&lt;p&gt;I managed to get a server-side JavaScript "hello world" demo working, but when I tried to add Pyodide I ran into some unavoidable road blocks. It turns out Service Workers are very restricted in which APIs they provide - in particular, they don't allow &lt;code&gt;XMLHttpRequest&lt;/code&gt; calls. Pyodide apparently depends on &lt;code&gt;XMLHttpRequest&lt;/code&gt;, so it was unable to run in a Service Worker at all. I &lt;a href="https://github.com/pyodide/pyodide/issues/2432"&gt;filed an issue&lt;/a&gt; about it with the Pyodide project.&lt;/p&gt;
&lt;p&gt;Initially I thought this would block the whole project, but eventually I figured out a way to achieve the same goals using Web Workers instead.&lt;/p&gt;
&lt;h3&gt;Is this an SPA or an MPA?&lt;/h3&gt;
&lt;p&gt;SPAs are Single Page Applications. MPAs are Multi Page Applications. Datasette Lite is a weird hybrid of the two.&lt;/p&gt;
&lt;p&gt;This amuses me greatly.&lt;/p&gt;
&lt;p&gt;Datasette itself is very deliberately architected as a multi page application.&lt;/p&gt;
&lt;p&gt;I think SPAs, as developed over the last decade, have mostly been a mistake. In my experience they take longer to build, have more bugs and provide worse performance than a server-side, multi-page alternative implementation.&lt;/p&gt;
&lt;p&gt;Obviously if you are building Figma or VS Code then SPAs are the right way to go. But most web applications are not Figma, and don't need to be!&lt;/p&gt;
&lt;p&gt;(I used to think Gmail was a shining example of an SPA, but it's so sludgy and slow loading these days that I now see it as more of an argument against the paradigm.)&lt;/p&gt;
&lt;p&gt;Datasette Lite is an SPA wrapper around an MPA. It literally simulates the existing MPA by running it in a web worker.&lt;/p&gt;
&lt;p&gt;It's very heavy - it loads 11MB of assets before it can show you anything. But it also inherits many of the benefits of the underlying MPA: it has obvious distinctions between pages, a deeply interlinked interface, working back and forward buttons, it's bookmarkable and it's easy to maintain and add new features.&lt;/p&gt;
&lt;p&gt;I'm not sure what my conclusion here is. I'm skeptical of SPAs, and now I've built a particularly weird one. Is this even a good idea? I'm looking forward to finding that out for myself.&lt;/p&gt;
&lt;h4&gt;Coming soon: JavaScript!&lt;/h4&gt;
&lt;p&gt;Another amusing detail about Datasette Lite is that the one part of Datasette that doesn't work yet is Datasette's existing JavaScript features!&lt;/p&gt;
&lt;p&gt;Datasette currently makes very sparing use of JavaScript in the UI: it's used to add some drop-down interactive menus (including the handy "cog" menu on column headings) and for a CodeMirror-enhanced SQL editing interface.&lt;/p&gt;
&lt;p&gt;JavaScript is used much more extensively by several popular Datasette plugins, including &lt;a href="https://datasette.io/plugins/datasette-cluster-map"&gt;datasette-cluster-map&lt;/a&gt; and &lt;a href="https://datasette.io/plugins/datasette-vega"&gt;datasette-vega&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Unfortunately none of this works in Datasette Lite at the moment - because I don't yet have a good way to turn &lt;code&gt;&amp;lt;script src="..."&amp;gt;&lt;/code&gt; links into things that can load content from the Web Worker.&lt;/p&gt;
&lt;p&gt;This is one of the reasons I was initially hopeful about Service Workers.&lt;/p&gt;
&lt;p&gt;Thankfully, since Datasette is built on the principles of progressive enhancement this doesn't matter: the application remains usable even if none of the JavaScript enhancements are applied.&lt;/p&gt;
&lt;p&gt;I have an &lt;a href="https://github.com/simonw/datasette-lite/issues/8"&gt;open issue for this&lt;/a&gt;. I welcome suggestions as to how I can get all of Datasette's existing JavaScript working in the new environment with as little effort as possible.&lt;/p&gt;
&lt;h4 id="bonus-shot-scraper"&gt;Bonus: Testing it with shot-scraper&lt;/h4&gt;
&lt;p&gt;In building Datasette Lite, I've committed to making Pyodide a supported runtime environment for Datasette. How can I ensure that future changes I make to Datasette - accidentally introducing a new dependency that doesn't work there for example - don't break in Pyodide without me noticing?&lt;/p&gt;
&lt;p&gt;This felt like a great opportunity to exercise my &lt;a href="https://datasette.io/tools/shot-scraper"&gt;shot-scraper&lt;/a&gt; CLI tool, in particular its ability to run some JavaScript against a page and &lt;a href="https://github.com/simonw/shot-scraper/blob/0.13/README.md#handling-javascript-errors"&gt;pass or fail a CI job&lt;/a&gt; depending on if that JavaScript throws an error.&lt;/p&gt;
&lt;p&gt;Pyodide needs you to run it from a real web server, not just an HTML file saved to disk - so I put together a &lt;a href="https://github.com/simonw/datasette/blob/280ff372ab30df244f6c54f6f3002da57334b3d7/test-in-pyodide-with-shot-scraper.sh"&gt;very scrappy shell script&lt;/a&gt; which builds a Datasette wheel package, starts a localhost file server (using &lt;code&gt;python3 -m http.server&lt;/code&gt;), then uses &lt;code&gt;shot-scraper javascript&lt;/code&gt; to execute a test against it that installs Datasette from the wheel using &lt;code&gt;micropip&lt;/code&gt; and confirms that it can execute a simple SQL query via the JSON API.&lt;/p&gt;
&lt;p&gt;Here's the script in full, with extra comments:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#!&lt;/span&gt;/bin/bash&lt;/span&gt;
&lt;span class="pl-c1"&gt;set&lt;/span&gt; -e
&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; I always forget to do this in my bash scripts - without it, any&lt;/span&gt;
&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; commands that fail in the script won't result in the script itself&lt;/span&gt;
&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; returning a non-zero exit code. I need it for running tests in CI.&lt;/span&gt;

&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; Build the wheel - this generates a file with a name similar to&lt;/span&gt;
&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; dist/datasette-0.62a0-py3-none-any.whl&lt;/span&gt;
python3 -m build

&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; Find the name of that wheel file, strip off the dist/&lt;/span&gt;
wheel=&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;$(&lt;/span&gt;basename &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;$(&lt;/span&gt;ls dist/&lt;span class="pl-k"&gt;*&lt;/span&gt;.whl&lt;span class="pl-pds"&gt;)&lt;/span&gt;&lt;/span&gt;&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; $wheel is now datasette-0.62a0-py3-none-any.whl&lt;/span&gt;

&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; Create a blank index page that loads Pyodide&lt;/span&gt;
&lt;span class="pl-c1"&gt;echo&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;&amp;lt;script src="https://cdn.jsdelivr.net/pyodide/v0.20.0/full/pyodide.js"&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;&amp;gt;&lt;/span&gt; dist/index.html

&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; Run a localhost web server for that dist/ folder, in the background&lt;/span&gt;
&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; so we can do more stuff in this script&lt;/span&gt;
&lt;span class="pl-c1"&gt;cd&lt;/span&gt; dist
python3 -m http.server 8529 &lt;span class="pl-k"&gt;&amp;amp;&lt;/span&gt;
&lt;span class="pl-c1"&gt;cd&lt;/span&gt; ..

&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; Now we use shot-scraper to run a block of JavaScript against our&lt;/span&gt;
&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; temporary web server. This will execute in the context of that&lt;/span&gt;
&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; index.html page we created earlier, which has loaded Pyodide&lt;/span&gt;
shot-scraper javascript http://localhost:8529/ &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;async () =&amp;gt; {&lt;/span&gt;
&lt;span class="pl-s"&gt;  // Load Pyodide and all of its necessary assets&lt;/span&gt;
&lt;span class="pl-s"&gt;  let pyodide = await loadPyodide();&lt;/span&gt;
&lt;span class="pl-s"&gt;  // We also need these packages for Datasette to work&lt;/span&gt;
&lt;span class="pl-s"&gt;  await pyodide.loadPackage(['micropip', 'ssl', 'setuptools']);&lt;/span&gt;
&lt;span class="pl-s"&gt;  // We need to escape the backticks because of Bash escaping rules&lt;/span&gt;
&lt;span class="pl-s"&gt;  let output = await pyodide.runPythonAsync(&lt;span class="pl-cce"&gt;\`&lt;/span&gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;    import micropip&lt;/span&gt;
&lt;span class="pl-s"&gt;    // This is needed to avoid a dependency conflict error&lt;/span&gt;
&lt;span class="pl-s"&gt;    await micropip.install('h11==0.12.0')&lt;/span&gt;
&lt;span class="pl-s"&gt;    // Here we install the Datasette wheel package we created earlier&lt;/span&gt;
&lt;span class="pl-s"&gt;    await micropip.install('http://localhost:8529/&lt;span class="pl-smi"&gt;$wheel&lt;/span&gt;')&lt;/span&gt;
&lt;span class="pl-s"&gt;    // These imports avoid Pyodide errors importing datasette itself&lt;/span&gt;
&lt;span class="pl-s"&gt;    import ssl&lt;/span&gt;
&lt;span class="pl-s"&gt;    import setuptools&lt;/span&gt;
&lt;span class="pl-s"&gt;    from datasette.app import Datasette&lt;/span&gt;
&lt;span class="pl-s"&gt;    // num_sql_threads=0 is essential or Datasette will crash, since&lt;/span&gt;
&lt;span class="pl-s"&gt;    // Pyodide and WebAssembly cannot start threads&lt;/span&gt;
&lt;span class="pl-s"&gt;    ds = Datasette(memory=True, settings={'num_sql_threads': 0})&lt;/span&gt;
&lt;span class="pl-s"&gt;    // Simulate a hit to execute 'select 55 as itworks' and return the text&lt;/span&gt;
&lt;span class="pl-s"&gt;    (await ds.client.get(&lt;/span&gt;
&lt;span class="pl-s"&gt;      '/_memory.json?sql=select+55+as+itworks&amp;amp;_shape=array'&lt;/span&gt;
&lt;span class="pl-s"&gt;    )).text&lt;/span&gt;
&lt;span class="pl-s"&gt;  &lt;span class="pl-cce"&gt;\`&lt;/span&gt;);&lt;/span&gt;
&lt;span class="pl-s"&gt;  // The last expression in the runPythonAsync block is returned, here&lt;/span&gt;
&lt;span class="pl-s"&gt;  // that's the text returned by the simulated HTTP response to the JSON API&lt;/span&gt;
&lt;span class="pl-s"&gt;  if (JSON.parse(output)[0].itworks != 55) {&lt;/span&gt;
&lt;span class="pl-s"&gt;    // This throws if the JSON API did not return the expected result&lt;/span&gt;
&lt;span class="pl-s"&gt;    // shot-scraper turns that into a non-zero exit code for the script&lt;/span&gt;
&lt;span class="pl-s"&gt;    // which will cause the CI task to fail&lt;/span&gt;
&lt;span class="pl-s"&gt;    throw 'Got ' + output + ', expected itworks: 55';&lt;/span&gt;
&lt;span class="pl-s"&gt;  }&lt;/span&gt;
&lt;span class="pl-s"&gt;  // This gets displayed on the console, with a 0 exit code for a pass&lt;/span&gt;
&lt;span class="pl-s"&gt;  return 'Test passed!';&lt;/span&gt;
&lt;span class="pl-s"&gt;}&lt;/span&gt;
&lt;span class="pl-s"&gt;&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; Shut down the server we started earlier, by searching for and killing&lt;/span&gt;
&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; a process that's running on the port we selected&lt;/span&gt;
pkill -f &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;http.server 8529&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&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/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/webworkers"&gt;webworkers&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;a href="https://simonwillison.net/tags/cors"&gt;cors&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="javascript"/><category term="projects"/><category term="python"/><category term="webworkers"/><category term="datasette"/><category term="webassembly"/><category term="pyodide"/><category term="datasette-lite"/><category term="cors"/></entry><entry><title>sqlite-utils 3.26.1</title><link href="https://simonwillison.net/2022/May/2/sqlite-utils/#atom-tag" rel="alternate"/><published>2022-05-02T18:43:14+00:00</published><updated>2022-05-02T18:43:14+00:00</updated><id>https://simonwillison.net/2022/May/2/sqlite-utils/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://sqlite-utils.datasette.io/en/stable/changelog.html#v3-26-1"&gt;sqlite-utils 3.26.1&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
I released sqlite-utils 3.36.1 with one tiny but exciting feature: I fixed its one dependency that wasn’t published as a pure Python wheel, which means it can now be used with Pyodide—Python compiled to WebAssembly running in your browser!

    &lt;p&gt;&lt;small&gt;&lt;/small&gt;Via &lt;a href="https://twitter.com/simonw/status/1521196799003877383"&gt;@simonw&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/webassembly"&gt;webassembly&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sqlite-utils"&gt;sqlite-utils&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/pyodide"&gt;pyodide&lt;/a&gt;&lt;/p&gt;



</summary><category term="python"/><category term="webassembly"/><category term="sqlite-utils"/><category term="pyodide"/></entry><entry><title>PyScript demos</title><link href="https://simonwillison.net/2022/Apr/30/pyscript-demos/#atom-tag" rel="alternate"/><published>2022-04-30T21:50:53+00:00</published><updated>2022-04-30T21:50:53+00:00</updated><id>https://simonwillison.net/2022/Apr/30/pyscript-demos/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://pyscript.net/examples/"&gt;PyScript demos&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
PyScript was announced at PyCon this morning. It’s a new open source project that provides Web Components built on top of Pyodide, allowing you to use Python directly within your HTML pages in a way that is executed using a WebAssembly copy of Python running in your browser. These demos really help illustrate what it can do—it’s a fascinating new piece of the Python web ecosystem.

    &lt;p&gt;&lt;small&gt;&lt;/small&gt;Via &lt;a href="https://twitter.com/simonw/status/1520452635886641153"&gt;@simonw&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/web-components"&gt;web-components&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="python"/><category term="web-components"/><category term="webassembly"/><category term="pyodide"/></entry><entry><title>WebAssembly in my Browser Desktop Environment</title><link href="https://simonwillison.net/2022/Mar/29/webassembly/#atom-tag" rel="alternate"/><published>2022-03-29T01:26:54+00:00</published><updated>2022-03-29T01:26:54+00:00</updated><id>https://simonwillison.net/2022/Mar/29/webassembly/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://www.wasm.builders/dustinbrett/webassembly-in-my-browser-desktop-environment-3alc"&gt;WebAssembly in my Browser Desktop Environment&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Dustin Brett built the WebAssembly demo to end all WebAssembly demos: his daedalOS browser desktop environment simulates a Windows-style operating system, and bundles WebAssembly projects that include v86 for 486 emulation, js-dos for DOS emulation to run Doom, BoxedWine to run Wine applications like Notepad++, Ruffle to emulate Flash, ffmpeg.wasm to power audio and video conversion, WASM-ImageMagick for image conversion,  Pyodide for a Python shell and more besides that!

    &lt;p&gt;&lt;small&gt;&lt;/small&gt;Via &lt;a href="https://news.ycombinator.com/item?id=30827497"&gt;Hacker News&lt;/a&gt;&lt;/small&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>Weeknotes: datasette-jupyterlite, s3-credentials and a Python packaging talk</title><link href="https://simonwillison.net/2021/Nov/5/datasette-jupyterlite/#atom-tag" rel="alternate"/><published>2021-11-05T05:04:58+00:00</published><updated>2021-11-05T05:04:58+00:00</updated><id>https://simonwillison.net/2021/Nov/5/datasette-jupyterlite/#atom-tag</id><summary type="html">
    &lt;p&gt;My big project this week was &lt;a href="https://simonwillison.net/2021/Nov/3/s3-credentials/"&gt;s3-credentials, described yesterday&lt;/a&gt; - but I also put together a fun expermiental Datasette plugin bundling JupyterLite and wrote up my PyGotham talk on Python packaging.&lt;/p&gt;
&lt;h4&gt;datasette-jupyterlite&lt;/h4&gt;
&lt;p&gt;&lt;a href="https://github.com/jupyterlite/jupyterlite"&gt;JupyterLite&lt;/a&gt; is absolutely incredible: it's a full, working distribution of Jupyter that runs entirely in a browser, thanks to a Python interpreter (and various other parts of the scientific Python stack) that has been compiled to WebAssembly by the &lt;a href="https://pyodide.org//"&gt;Pyodide&lt;/a&gt; project.&lt;/p&gt;
&lt;p&gt;Since it's just static JavaScript (and WASM modules) it's possible to host it anywhere that can run a web server.&lt;/p&gt;
&lt;p&gt;Datasette runs a web server...&lt;/p&gt;
&lt;p&gt;So, I built &lt;a href="https://datasette.io/plugins/datasette-jupyterlite"&gt;datasette-jupyterlite&lt;/a&gt; - a Datasette plugin that bundles JupyterLite and serves it up as part of the Datasette instance.&lt;/p&gt;
&lt;p&gt;You can try &lt;a href="https://latest-with-plugins.datasette.io/jupyterlite/"&gt;a live demo here&lt;/a&gt;:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2021/datasette-jupyterlite.png" alt="Screenshot showing JupyterLite hosted by Datasette" style="max-width:100%;" /&gt;&lt;/p&gt;
&lt;p&gt;Here's some Python code that will retrieve data from the associated Datasette instance and pull it into a Pandas DataFrame:&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-s1"&gt;pyodide&lt;/span&gt;
&lt;span class="pl-s1"&gt;pandas&lt;/span&gt;.&lt;span class="pl-en"&gt;read_csv&lt;/span&gt;(&lt;span class="pl-s1"&gt;pyodide&lt;/span&gt;.&lt;span class="pl-en"&gt;open_url&lt;/span&gt;(
  &lt;span class="pl-s"&gt;"https://latest-with-plugins.datasette.io/github/stars.csv"&lt;/span&gt;)
)&lt;/pre&gt;
&lt;p&gt;(I haven't yet found a way to do this with a relative rather than absolute URL.)&lt;/p&gt;
&lt;p&gt;The best part of this is that it works in &lt;a href="https://datasette.io/desktop"&gt;Datasette Desktop&lt;/a&gt;! You can install the plugin using the "Install and manage plugins" menu item to get a version of Jupyter running in Python running in WebAssembly running in V8 running in Chromium running in Electron.&lt;/p&gt;
&lt;p&gt;The plugin implementation is &lt;a href="https://github.com/simonw/datasette-jupyterlite/blob/0.1a0/datasette_jupyterlite/__init__.py"&gt;just 30 lines of code&lt;/a&gt; - it uses the &lt;a href="https://pypi.org/project/jupyterlite/"&gt;jupyterlite&lt;/a&gt; Python package which bundles a &lt;code&gt;.tgz&lt;/code&gt; file containing all of the required static assets, then serves files directly out of that tarfile.&lt;/p&gt;
&lt;h4&gt;Also this week&lt;/h4&gt;
&lt;p&gt;My other projects from this week are already written about on the blog:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://simonwillison.net/2021/Nov/3/s3-credentials/"&gt;s3-credentials: a tool for creating credentials for S3 buckets&lt;/a&gt; introduces a new CLI tool I built that automates the process of creating new, long-lived credentials that provide read-only, read-write or write-only access to just a single specified S3 bucket.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://simonwillison.net/2021/Nov/4/publish-open-source-python-library/"&gt;How to build, test and publish an open source Python library&lt;/a&gt; is a detailed write-up of the 10 minute workshop I presented at PyGotham this year showing how to create a Python library, bundle it up as a package using &lt;code&gt;setup.py&lt;/code&gt;, publish it to &lt;a href="https://pypi.org/"&gt;PyPI&lt;/a&gt; and then set up GitHub Actions to test and publish future releases.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;Releases this week&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/simonw/s3-credentials"&gt;s3-credentials&lt;/a&gt;&lt;/strong&gt;: &lt;a href="https://github.com/simonw/s3-credentials/releases/tag/0.4"&gt;0.4&lt;/a&gt; - (&lt;a href="https://github.com/simonw/s3-credentials/releases"&gt;4 releases total&lt;/a&gt;) - 2021-11-04
&lt;br /&gt;A tool for creating credentials for accessing S3 buckets&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/simonw/datasette-notebook"&gt;datasette-notebook&lt;/a&gt;&lt;/strong&gt;: &lt;a href="https://github.com/simonw/datasette-notebook/releases/tag/0.2a0"&gt;0.2a0&lt;/a&gt; - (&lt;a href="https://github.com/simonw/datasette-notebook/releases"&gt;4 releases total&lt;/a&gt;) - 2021-11-02
&lt;br /&gt;A markdown wiki and dashboarding system for Datasette&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/simonw/datasette-jupyterlite"&gt;datasette-jupyterlite&lt;/a&gt;&lt;/strong&gt;: &lt;a href="https://github.com/simonw/datasette-jupyterlite/releases/tag/0.1a0"&gt;0.1a0&lt;/a&gt; - 2021-11-01
&lt;br /&gt;JupyterLite as a Datasette plugin&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;TIL this week&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://til.simonwillison.net/pytest/pytest-recording-vcr"&gt;Using VCR and pytest with pytest-recording&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://til.simonwillison.net/pytest/pytest-mock-calls"&gt;Quick and dirty mock testing with mock_calls&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/jupyter"&gt;jupyter&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/weeknotes"&gt;weeknotes&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/pyodide"&gt;pyodide&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="projects"/><category term="jupyter"/><category term="datasette"/><category term="webassembly"/><category term="weeknotes"/><category term="pyodide"/></entry><entry><title>Pyodide: Bringing the scientific Python stack to the browser</title><link href="https://simonwillison.net/2019/Apr/17/pyodide-bringing-scientific-python-stack-browser/#atom-tag" rel="alternate"/><published>2019-04-17T04:23:33+00:00</published><updated>2019-04-17T04:23:33+00:00</updated><id>https://simonwillison.net/2019/Apr/17/pyodide-bringing-scientific-python-stack-browser/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hacks.mozilla.org/2019/04/pyodide-bringing-the-scientific-python-stack-to-the-browser/"&gt;Pyodide: Bringing the scientific Python stack to the browser&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
More fun with WebAssembly: Pyodide attempts (and mostly succeeds) to bring the full Python data stack to the browser: CPython, NumPy, Pandas, Scipy, and Matplotlib. Also includes interesting bridge tools for e.g. driving a canvas element from Python. Really interesting project from the Firefox Data Platform team.

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


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/mozilla"&gt;mozilla&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/pandas"&gt;pandas&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/scipy"&gt;scipy&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/webassembly"&gt;webassembly&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/numpy"&gt;numpy&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/pyodide"&gt;pyodide&lt;/a&gt;&lt;/p&gt;



</summary><category term="mozilla"/><category term="pandas"/><category term="python"/><category term="scipy"/><category term="webassembly"/><category term="numpy"/><category term="pyodide"/></entry></feed>