<?xml version='1.0' encoding='UTF-8'?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <id>http://til.simonwillison.net/tils/feed.atom</id>
  <title>Simon Willison TIL</title>
  <updated>2026-07-14T00:56:20+00:00</updated>
  <link href="http://til.simonwillison.net/tils/feed.atom" rel="self"/>
  <generator uri="https://github.com/simonw/datasette" version="0.65.2">Datasette</generator>
  <entry>
    <id>tag:til.simonwillison.net,2020-04-30:github-actions_uvx-github-actions-cache.md</id>
    <title>Using uvx in GitHub Actions in a cache-friendly way</title>
    <updated>2026-07-14T00:56:20+00:00</updated>
    <author>
      <name>Simon Willison</name>
      <uri>https://simonwillison.net/</uri>
    </author>
    <content type="html">&lt;p&gt;I often find myself wanting to run a quick Python tool inside of GitHub Actions using &lt;code&gt;uvx name-of-tool&lt;/code&gt; - but I don't want that to result in a network request to PyPI every time the workflow runs. I want the tool to be fetched the first time and then reused from the GitHub Actions cache for subsequent runs.&lt;/p&gt;
&lt;p&gt;I've tried unsuccessfully to find patterns I like for this in the past, especially given the standard pattern in GitHub Actions of using the hashed contents of a file - often &lt;code&gt;pyproject.toml&lt;/code&gt; or &lt;code&gt;requirements.txt&lt;/code&gt; - as a key for the cache.&lt;/p&gt;
&lt;p&gt;This is usually a good pattern, but for simple scripts I don't want to have to maintain an additional file just to get the cache to work correctly.&lt;/p&gt;
&lt;p&gt;Today (with the &lt;a href="https://chatgpt.com/share/6a5586a8-68d0-83e8-a78a-f01ec2472c58" rel="nofollow"&gt;help of GPT-5.6 Sol&lt;/a&gt;) I finally found a pattern I like.&lt;/p&gt;
&lt;p&gt;My goal was to be able to drop &lt;code&gt;uvx name-of-tool&lt;/code&gt; into a GitHub Actions workflow anywhere I like, while still trusting that the tool would be cached between builds - and could be cache-invalidated if I needed to.&lt;/p&gt;
&lt;p&gt;The key turned out to be the &lt;a href="https://docs.astral.sh/uv/reference/environment/#uv_exclude_newer" rel="nofollow"&gt;UV_EXCLUDE_NEWER&lt;/a&gt; environment variable. This works the same as &lt;code&gt;uvx --exclude-newer DATE&lt;/code&gt;, allowing you to tell &lt;code&gt;uv&lt;/code&gt; to install the most recent package as-of a specific date.&lt;/p&gt;
&lt;p&gt;That date can then also be used as part of the cache key for GitHub Actions! This means you can set the date in the script once and get a repeatable set of installed versions for all of the tools. Then any time you want to bust the cache you can increment the date in that one place:&lt;/p&gt;
&lt;div class="highlight highlight-source-yaml"&gt;&lt;pre&gt;&lt;span class="pl-ent"&gt;name&lt;/span&gt;: &lt;span class="pl-s"&gt;Run tools&lt;/span&gt;

&lt;span class="pl-ent"&gt;on&lt;/span&gt;:
  &lt;span class="pl-ent"&gt;workflow_dispatch&lt;/span&gt;:

&lt;span class="pl-ent"&gt;env&lt;/span&gt;:
  &lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; Bump this date to allow newer package releases and a fresh cache:&lt;/span&gt;
  &lt;span class="pl-ent"&gt;UV_EXCLUDE_NEWER&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;2026-07-12&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;

&lt;span class="pl-ent"&gt;jobs&lt;/span&gt;:
  &lt;span class="pl-ent"&gt;test&lt;/span&gt;:
    &lt;span class="pl-ent"&gt;runs-on&lt;/span&gt;: &lt;span class="pl-s"&gt;ubuntu-latest&lt;/span&gt;

    &lt;span class="pl-ent"&gt;steps&lt;/span&gt;:
      - &lt;span class="pl-ent"&gt;name&lt;/span&gt;: &lt;span class="pl-s"&gt;Install uv and restore cache&lt;/span&gt;
        &lt;span class="pl-ent"&gt;id&lt;/span&gt;: &lt;span class="pl-s"&gt;setup-uv&lt;/span&gt;
        &lt;span class="pl-ent"&gt;uses&lt;/span&gt;: &lt;span class="pl-s"&gt;astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 &lt;/span&gt;&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; v8.3.2&lt;/span&gt;
        &lt;span class="pl-ent"&gt;with&lt;/span&gt;:
          &lt;span class="pl-ent"&gt;enable-cache&lt;/span&gt;: &lt;span class="pl-c1"&gt;true&lt;/span&gt;
          &lt;span class="pl-ent"&gt;cache-dependency-glob&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
          &lt;span class="pl-ent"&gt;cache-suffix&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;tools-${{ env.UV_EXCLUDE_NEWER }}&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
          &lt;span class="pl-ent"&gt;prune-cache&lt;/span&gt;: &lt;span class="pl-c1"&gt;false&lt;/span&gt;
    
      - &lt;span class="pl-ent"&gt;name&lt;/span&gt;: &lt;span class="pl-s"&gt;Require cache-only uv on cache hits&lt;/span&gt;
        &lt;span class="pl-ent"&gt;if&lt;/span&gt;: &lt;span class="pl-s"&gt;steps.setup-uv.outputs.cache-hit == 'true'&lt;/span&gt;
        &lt;span class="pl-ent"&gt;run&lt;/span&gt;: &lt;span class="pl-s"&gt;echo "UV_OFFLINE=1" &amp;gt;&amp;gt; "$GITHUB_ENV"&lt;/span&gt;
    
      - &lt;span class="pl-ent"&gt;name&lt;/span&gt;: &lt;span class="pl-s"&gt;Run sqlite-utils&lt;/span&gt;
        &lt;span class="pl-ent"&gt;run&lt;/span&gt;: &lt;span class="pl-s"&gt;uvx sqlite-utils --version&lt;/span&gt;
    
      - &lt;span class="pl-ent"&gt;name&lt;/span&gt;: &lt;span class="pl-s"&gt;Run datasette&lt;/span&gt;
        &lt;span class="pl-ent"&gt;run&lt;/span&gt;: &lt;span class="pl-s"&gt;uvx --pre datasette --version&lt;/span&gt;
    
      - &lt;span class="pl-ent"&gt;name&lt;/span&gt;: &lt;span class="pl-s"&gt;Run LLM&lt;/span&gt;
        &lt;span class="pl-ent"&gt;run&lt;/span&gt;: &lt;span class="pl-s"&gt;uvx llm --version&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;a href="https://github.com/astral-sh/setup-uv"&gt;astral-sh/setup-uv&lt;/a&gt; is Astral's official Action for getting &lt;code&gt;uv&lt;/code&gt;. I'm annoyed that it appears to hit Astral's own &lt;code&gt;releases.astral.sh&lt;/code&gt; site every time it runs but if that's how they want it to work I guess that's on them.&lt;/p&gt;
&lt;p&gt;Those settings:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;enable-cache: true&lt;/code&gt; turns on GitHub Actions caching&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cache-dependency-glob: ""&lt;/code&gt; disables the feature where it looks for &lt;code&gt;pyproject.toml&lt;/code&gt; or similar to use as a cache key&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cache-suffix: "tools-${{ env.UV_EXCLUDE_NEWER }}"&lt;/code&gt; is the bit that uses our single &lt;code&gt;UV_EXCLUDE_NEWER&lt;/code&gt; value for the cache key&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;prune-cache: false&lt;/code&gt; is necessary because Astral default to deliberately pruning your cache of any downloaded wheels, the exact opposite of what I want!&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I should note that my preferences here go directly against &lt;a href="https://docs.astral.sh/uv/concepts/cache/#caching-in-continuous-integration" rel="nofollow"&gt;what uv advises&lt;/a&gt;:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;However, in continuous integration environments, persisting pre-built wheels may be undesirable. With uv, it turns out that it's often faster to &lt;em&gt;omit&lt;/em&gt; pre-built wheels from the cache (and instead re-download them from the registry on each run).&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Personally I'd rather suffer from very slightly slower CI builds (presumably because GitHub's cache restore operations are slower than fresh installations from PyPI?) than optimize my builds by hitting the PyPI CDN for every tool execution.&lt;/p&gt;
&lt;p&gt;This block here enforces that the cache is used correctly:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;      - name: Require cache-only uv on cache hits
        if: steps.setup-uv.outputs.cache-hit == 'true'
        run: echo "UV_OFFLINE=1" &amp;gt;&amp;gt; "$GITHUB_ENV"
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Setting that &lt;code&gt;UV_OFFLINE=1&lt;/code&gt; environment variable causes &lt;code&gt;uvx tool-name&lt;/code&gt; to fail if the tool has not been previously installed. We only run that if we got a cache hit from the GitHub Actions cache.&lt;/p&gt;
&lt;p&gt;This means that if you add a new tool to the workflow without also bumping the &lt;code&gt;UV_EXCLUDE_NEWER&lt;/code&gt; date you'll get an error.&lt;/p&gt;
</content>
    <link href="https://til.simonwillison.net/github-actions/uvx-github-actions-cache"/>
  </entry>
  <entry>
    <id>tag:til.simonwillison.net,2020-04-30:cloudflare_captcha-on-at-least-one-ampersand.md</id>
    <title>Cloudflare CAPTCHA on at least one ampersand</title>
    <updated>2026-06-16T00:21:36+00:00</updated>
    <author>
      <name>Simon Willison</name>
      <uri>https://simonwillison.net/</uri>
    </author>
    <content type="html">&lt;p&gt;I use Cloudflare's CAPTCHA (they call it a "Managed Challenge") on &lt;a href="https://simonwillison.net/search/" rel="nofollow"&gt;simonwillison.net/search/&lt;/a&gt; to prevent crawlers from following every single possible combination of my &lt;a href="https://simonwillison.net/2017/Oct/5/django-postgresql-faceted-search/" rel="nofollow"&gt;faceted search&lt;/a&gt; UI.&lt;/p&gt;
&lt;p&gt;This was getting pretty annoying, since I had to wait for the challenge every time I searched my own site.&lt;/p&gt;
&lt;p&gt;I don't particularly care about regular &lt;code&gt;?q=term&lt;/code&gt; searches. Where things get messy is if a crawler starts hitting every combination of:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;?q=term&amp;amp;type=entry&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;?q=term&amp;amp;type=entry&amp;amp;year=2006&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;?q=term&amp;amp;type=entry&amp;amp;year=2006&amp;amp;tag=browsers&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;etc.&lt;/p&gt;
&lt;p&gt;I decided to switch the Cloudflare rules around to activating only on hits to &lt;code&gt;/search/&lt;/code&gt; that included at least one &lt;code&gt;&amp;amp;&lt;/code&gt; in the query string section.&lt;/p&gt;
&lt;p&gt;Here's what that rule expression looks like:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;(http.request.uri.path wildcard r"/search/*" and http.request.uri.query contains "&amp;amp;")&lt;/code&gt;&lt;/p&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;Trying the Cloudflare MCP&lt;/h2&gt;&lt;a id="user-content-trying-the-cloudflare-mcp" class="anchor" aria-label="Permalink: Trying the Cloudflare MCP" href="#trying-the-cloudflare-mcp"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;I originally tried to figure this out using Claude Code and &lt;a href="https://github.com/cloudflare/mcp"&gt;Cloudflare's MCP server&lt;/a&gt;. I got that working by creating a dedicated folder:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;mkdir cloudflare-dev
&lt;span class="pl-c1"&gt;cd&lt;/span&gt; cloudflare-dev&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;And then setting up the MCP so it would only be active for Claude Code sessions started in that folder:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&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;  "mcpServers": {&lt;/span&gt;
&lt;span class="pl-s"&gt;    "cloudflare-api": {&lt;/span&gt;
&lt;span class="pl-s"&gt;      "type": "http",&lt;/span&gt;
&lt;span class="pl-s"&gt;      "url": "https://mcp.cloudflare.com/mcp"&lt;/span&gt;
&lt;span class="pl-s"&gt;    }&lt;/span&gt;
&lt;span class="pl-s"&gt;  }&lt;/span&gt;
&lt;span class="pl-s"&gt;}&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;&amp;gt;&lt;/span&gt; .mcp.json
mkdir .claude
&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;  "enabledMcpjsonServers": [&lt;/span&gt;
&lt;span class="pl-s"&gt;    "cloudflare-api"&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-k"&gt;&amp;gt;&lt;/span&gt; .claude/settings.local.json&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;(I actually set it up by pasting the MCP JSON into Claude Code and saying "set this up to only work in this project folder", but the above is effectively what it did.)&lt;/p&gt;
&lt;p&gt;Then I ran &lt;code&gt;claude&lt;/code&gt; in the folder and used the &lt;code&gt;/mcp&lt;/code&gt; command, selected the Cloudflare MCP and used the authenticate option to jump through an OAuth flow.&lt;/p&gt;
&lt;p&gt;... which didn't work, because as far as I can tell Cloudflare's MCP doesn't yet implement tools to view and modify the rules in question.&lt;/p&gt;
&lt;p&gt;Claude did suggest using the API instead, but I'd need an API token.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;Using the API instead&lt;/h2&gt;&lt;a id="user-content-using-the-api-instead" class="anchor" aria-label="Permalink: Using the API instead" href="#using-the-api-instead"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;I created an API token using &lt;a href="https://dash.cloudflare.com/profile/api-tokens" rel="nofollow"&gt;dash.cloudflare.com/profile/api-tokens&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Cloudflare have a template for "Read all resources", and it turns out you can use that as a starting point.&lt;/p&gt;
&lt;p&gt;I flipped the "Zone WAF" one to "Edit" and set the key to expire tomorrow. Then I copied the resulting key into a &lt;code&gt;token.txt&lt;/code&gt; file.&lt;/p&gt;
&lt;p&gt;(In the Cloudflare dashboard I believe this feature is called "Web Application Firewall &amp;gt; Custom rules".)&lt;/p&gt;
&lt;p&gt;Then I let Claude Code handle the rest. Here's a rough version of what it did, assuming a token in a &lt;code&gt;$TOKEN&lt;/code&gt; environment variable:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;export&lt;/span&gt; TOKEN=&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;$(&lt;/span&gt;cat token.txt&lt;span class="pl-pds"&gt;)&lt;/span&gt;&lt;/span&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
curl -s -H &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Authorization: Bearer &lt;span class="pl-smi"&gt;$TOKEN&lt;/span&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; \
  &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;https://api.cloudflare.com/client/v4/zones?name=simonwillison.net&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; \
  &lt;span class="pl-k"&gt;|&lt;/span&gt; jq &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;{success, errors, zones: [.result[] | {id, name}]}&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;This got back the zone ID, which is &lt;code&gt;2ce4f4f41f239d041e25f8320ad3c3fd&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Then to list the custom WAF rules:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;export&lt;/span&gt; ZONE=&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;2ce4f4f41f239d041e25f8320ad3c3fd&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
curl -s -H &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Authorization: Bearer &lt;span class="pl-smi"&gt;$TOKEN&lt;/span&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; \
  &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;https://api.cloudflare.com/client/v4/zones/&lt;span class="pl-smi"&gt;$ZONE&lt;/span&gt;/rulesets/phases/http_request_firewall_custom/entrypoint&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; \
  &lt;span class="pl-k"&gt;|&lt;/span&gt; jq &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;{success, errors, rules: [.result.rules[]? | {description, action, expression, enabled}]}&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;This started with:&lt;/p&gt;
&lt;div class="highlight highlight-source-json"&gt;&lt;pre&gt;{
  &lt;span class="pl-ent"&gt;"success"&lt;/span&gt;: &lt;span class="pl-c1"&gt;true&lt;/span&gt;,
  &lt;span class="pl-ent"&gt;"errors"&lt;/span&gt;: [],
  &lt;span class="pl-ent"&gt;"rules"&lt;/span&gt;: [
    {
      &lt;span class="pl-ent"&gt;"description"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;/search/ extra protection&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
      &lt;span class="pl-ent"&gt;"action"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;managed_challenge&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
      &lt;span class="pl-ent"&gt;"expression"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;(http.request.uri.path wildcard r&lt;span class="pl-cce"&gt;\"&lt;/span&gt;/search/*&lt;span class="pl-cce"&gt;\"&lt;/span&gt;)&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
      &lt;span class="pl-ent"&gt;"enabled"&lt;/span&gt;: &lt;span class="pl-c1"&gt;true&lt;/span&gt;
    },&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;To edit that rule via API we need the ruleset ID and the rule ID:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;curl -s -H &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Authorization: Bearer &lt;span class="pl-smi"&gt;$TOKEN&lt;/span&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; \
  &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;https://api.cloudflare.com/client/v4/zones/&lt;span class="pl-smi"&gt;$ZONE&lt;/span&gt;/rulesets/phases/http_request_firewall_custom/entrypoint&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; \
  &lt;span class="pl-k"&gt;|&lt;/span&gt; jq &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;{ruleset_id: .result.id, rule: (.result.rules[] | select(.description=="/search/ extra protection") | {id, description, action, expression, enabled})}&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Returning:&lt;/p&gt;
&lt;div class="highlight highlight-source-json"&gt;&lt;pre&gt;{
  &lt;span class="pl-ent"&gt;"ruleset_id"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;0682fdbd40cc444cbe1e93d136e2b174&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
  &lt;span class="pl-ent"&gt;"rule"&lt;/span&gt;: {
    &lt;span class="pl-ent"&gt;"id"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;8b2766d7802e4e988163531670976cb9&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
    &lt;span class="pl-ent"&gt;"description"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;/search/ extra protection&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
    &lt;span class="pl-ent"&gt;"action"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;managed_challenge&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
    &lt;span class="pl-ent"&gt;"expression"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;(http.request.uri.path wildcard r&lt;span class="pl-cce"&gt;\"&lt;/span&gt;/search/*&lt;span class="pl-cce"&gt;\"&lt;/span&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
    &lt;span class="pl-ent"&gt;"enabled"&lt;/span&gt;: &lt;span class="pl-c1"&gt;true&lt;/span&gt;
  }
}&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;And finally we can update that with the new expression:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;export&lt;/span&gt; RS=0682fdbd40cc444cbe1e93d136e2b174
&lt;span class="pl-k"&gt;export&lt;/span&gt; RULE=8b2766d7802e4e988163531670976cb9

curl -s -X PATCH \
  -H &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Authorization: Bearer &lt;span class="pl-smi"&gt;$TOKEN&lt;/span&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; \
  -H &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Content-Type: application/json&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; \
  &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;https://api.cloudflare.com/client/v4/zones/&lt;span class="pl-smi"&gt;$ZONE&lt;/span&gt;/rulesets/&lt;span class="pl-smi"&gt;$RS&lt;/span&gt;/rules/&lt;span class="pl-smi"&gt;$RULE&lt;/span&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; \
  --data &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;{&lt;/span&gt;
&lt;span class="pl-s"&gt;    "action": "managed_challenge",&lt;/span&gt;
&lt;span class="pl-s"&gt;    "expression": "(http.request.uri.path wildcard r\"/search/*\" and http.request.uri.query contains \"&amp;amp;\")",&lt;/span&gt;
&lt;span class="pl-s"&gt;    "description": "/search/ extra protection",&lt;/span&gt;
&lt;span class="pl-s"&gt;    "enabled": true&lt;/span&gt;
&lt;span class="pl-s"&gt;  }&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
</content>
    <link href="https://til.simonwillison.net/cloudflare/captcha-on-at-least-one-ampersand"/>
  </entry>
  <entry>
    <id>tag:til.simonwillison.net,2020-04-30:llms_agentsview-custom-model-price.md</id>
    <title>Setting a custom price for a model in AgentsView</title>
    <updated>2026-06-09T21:35:31+00:00</updated>
    <author>
      <name>Simon Willison</name>
      <uri>https://simonwillison.net/</uri>
    </author>
    <content type="html">&lt;p&gt;I'm a recent convent to &lt;a href="https://www.agentsview.io" rel="nofollow"&gt;AgentsView&lt;/a&gt;, Wes McKinney's (previously of Pandas fame) Python toolkit for analyzing transcripts of coding agents from your own computer.&lt;/p&gt;
&lt;p&gt;AgentsView can calculate your token spending based on those transcripts, across multiple different coding agents (Claude Code, Codex, Pi and more.)&lt;/p&gt;
&lt;p&gt;You can run it via &lt;code&gt;uvx&lt;/code&gt; like this to get as ASCII table of spending numbers in your terminal:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;uvx agentsview usage daily
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Example output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;DATE        INPUT     OUTPUT   CACHE_CR  CACHE_RD    COST      MODELS
----        -----     ------   --------  --------    ----      ------
...
2026-06-04  5248968   387578   149428    78087993    $77.72    gpt-5.5, claude-opus-4-8
2026-06-05  963595    115773   0         8383360     $8.62     gpt-5.5, gpt-5.4-mini, deepseek/deepseek-v4-flash, meta-llama/llama-3.2-3b-instruct:free, nvidia/nemotron-nano-9b-v2:free
2026-06-06  324888    36869    0         3231872     $4.35     gpt-5.5
2026-06-07  669027    46379    0         7486464     $8.48     gpt-5.5
2026-06-08  3346262   253637   0         49214208    $48.95    gpt-5.5
2026-06-09  787043    427018   1098673   78305086    $97.79    claude-fable-5, gpt-5.5, claude-haiku-4-5-20251001
----        -----     ------   --------  --------    ----      ------
TOTAL       73610282  7260576  4399393   1717538611  $1472.87  
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Anthropic's latest model &lt;a href="https://www.anthropic.com/news/claude-fable-5-mythos-5" rel="nofollow"&gt;Claude Fable 5&lt;/a&gt; came out today. The pricing data AgentsView uses doesn't yet include that model.&lt;/p&gt;
&lt;p&gt;So I had &lt;a href="https://claude.ai/share/28310314-36fe-4bc7-bf04-4060714ed5de" rel="nofollow"&gt;Claude Fable 5 reverse engineer AgentsView&lt;/a&gt; with the following prompt against &lt;a href="https://claude.ai" rel="nofollow"&gt;claude.ai&lt;/a&gt;:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Clone https://github.com/kenn-io/agentsview and tell me how I can set a price for a model that it does not know the price of yet&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;It turns out you can add custom pricing information to your &lt;code&gt;~/.agentsview/config.toml&lt;/code&gt; file. Here's what I added:&lt;/p&gt;
&lt;div class="highlight highlight-source-toml"&gt;&lt;pre&gt;[&lt;span class="pl-en"&gt;custom_model_pricing&lt;/span&gt;.&lt;span class="pl-en"&gt;"claude-fable-5"&lt;/span&gt;]
&lt;span class="pl-smi"&gt;input&lt;/span&gt; = &lt;span class="pl-c1"&gt;10.0&lt;/span&gt;
&lt;span class="pl-smi"&gt;output&lt;/span&gt; = &lt;span class="pl-c1"&gt;50.0&lt;/span&gt;
&lt;span class="pl-smi"&gt;cache_creation&lt;/span&gt; = &lt;span class="pl-c1"&gt;12.50&lt;/span&gt;
&lt;span class="pl-smi"&gt;cache_read&lt;/span&gt; = &lt;span class="pl-c1"&gt;1&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;I think those numbers are right, I got them &lt;a href="https://platform.claude.com/docs/en/about-claude/pricing" rel="nofollow"&gt;from this pricing page&lt;/a&gt;. Fable is 2x the price of Opus for input and output.&lt;/p&gt;
&lt;p&gt;With the config file edited I can run Fable again to get pricing estimates. I used the &lt;code&gt;serve&lt;/code&gt; command to get a web application on port 8080:&lt;/p&gt;
&lt;p&gt;&lt;a target="_blank" rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/50f615e359e85b3af02bcaf015882251b52437e73010300ed8757c2ed442a534/68747470733a2f2f7374617469632e73696d6f6e77696c6c69736f6e2e6e65742f7374617469632f323032362f6167656e7473766965772d6661626c652e6a7067"&gt;&lt;img src="https://camo.githubusercontent.com/50f615e359e85b3af02bcaf015882251b52437e73010300ed8757c2ed442a534/68747470733a2f2f7374617469632e73696d6f6e77696c6c69736f6e2e6e65742f7374617469632f323032362f6167656e7473766965772d6661626c652e6a7067" alt="Screenshot of a cost analytics dashboard. Cost Attribution - Click to hide from chart - toggle buttons for Project / Model / Agent and Treemap / List. A treemap shows a large red block: prod_datasette_agent $74.06 89.3%, then blue: cloud $3.98 4.8%, teal: datasette $2.81 3.4%, pink: money $1.92 2.3%, and a thin orange sliver. A legend lists 1 prod_datasette_agent $74.06, 2 cloud $3.98, 3 datasette $2.81, 4 money $1.92, 5 simon $0.15. Below left, Top Sessions by Cost: 1 Claude - Review ./datasette-agent and ./datasette-apps - we are going to a... - prod_datasette_agent · 08a1f374-0e77-420f-be2d-af805d67e8aa - 55.9M $74.06; 2 Claude - issues.db is a copy of the Datasette issues database. There are a... - datasette · 8caa2d2d-b91f-43b3-bf3a-4268995b6011 - 826.8k $2.81; 3 Claude - Consult fly-docs and then look at datasette.cloud (which launche... - cloud · bfcacc70-09d7-4b27-aaec-4bb8accd9fec - 924.7k $2.61; 4 Claude - simonwillisonblog.db is a copy of my blog, plus all my software re... - money · 0c0fb9dc-6347-4e1b-9307-3709a7cdf0c8 - 542.9k $1.92; 5 Claude - Look in datasette.cloud and figure out all remaining steps and dec... - cloud · 45963b5f-608a-4caa-ad6b-6ae81e1dbf0d - 455k $1.37; 6 Claude - simon - simon · deeccb5d-9e90-4b1e-bfe6-c2b271e1b1d4 - 26.4k $0.15. Below right, Cache Efficiency with horizontal bars: Cache Reads 57.6M (nearly full green bar), Cache Writes 769.3K, Uncached Input 64.4K, Output 300.9K (all tiny bars), and a green highlighted note: $516.62 saved vs uncached." data-canonical-src="https://static.simonwillison.net/static/2026/agentsview-fable.jpg" style="max-width: 100%;"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;I've used the equivalent of $82.92 in tokens since getting access to Fable 5 about four and a half hours ago. This is all included in my $100/month Claude Max subscription though, which &lt;a href="https://simonwillison.net/2026/May/27/product-market-fit/#enterprise-customers-are-now-paying-api-prices" rel="nofollow"&gt;based on prior experience&lt;/a&gt; will likely give me around 10x the token usage compared to if I was paying list price.&lt;/p&gt;
</content>
    <link href="https://til.simonwillison.net/llms/agentsview-custom-model-price"/>
  </entry>
  <entry>
    <id>tag:til.simonwillison.net,2020-04-30:llms_llm-shebang.md</id>
    <title>Using LLM in the shebang line of a script</title>
    <updated>2026-05-11T18:48:57+00:00</updated>
    <author>
      <name>Simon Willison</name>
      <uri>https://simonwillison.net/</uri>
    </author>
    <content type="html">&lt;p&gt;&lt;a href="https://news.ycombinator.com/item?id=48073246#48090590" rel="nofollow"&gt;This comment&lt;/a&gt; on Hacker News inspired me to investigate patterns for using my &lt;a href="https://llm.datasette.io/" rel="nofollow"&gt;LLM&lt;/a&gt; CLI tool in a shebang line:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;But seriously, you can put a shebang on an english text file now (if you're sufficiently brave) [...]&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;LLM can end up installed in all sorts of unpredictable places so the best way to run it is via the &lt;code&gt;#!/usr/bin/env&lt;/code&gt; pattern.&lt;/p&gt;
&lt;p&gt;Here's how to make English (or Spanish or any other language) text executable via LLM:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#!/usr/bin/env -S llm -f
Generate an SVG of a pelican riding a bicycle
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Save this as &lt;code&gt;pelican.sh&lt;/code&gt; and make it executable with:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;chmod +x pelican.sh&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Then run it:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;./pelican.sh&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Other arguments will be passed through to LLM, so if you want to use a different model:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;./pelican.sh -m gpt-5.4-nano&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;This prompt often returns commentary in addition to an SVG. To extract just the first code block in the response add the &lt;code&gt;-x&lt;/code&gt; LLM option:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#!/usr/bin/env -S llm -x -f
Generate an SVG of a pelican riding a bicycle
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;-f&lt;/code&gt; option needs to come last as it will be passed the path to the script file.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;How this works&lt;/h2&gt;&lt;a id="user-content-how-this-works" class="anchor" aria-label="Permalink: How this works" href="#how-this-works"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;pre&gt;&lt;code&gt;#!/usr/bin/env -S llm -f
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;-S&lt;/code&gt; (for split) option to &lt;code&gt;env&lt;/code&gt; is required because, without it, the &lt;code&gt;env&lt;/code&gt; command will treat the rest of the line as the full name of the command, producing this error:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;/usr/bin/env: 'llm -f': No such file or directory&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;With &lt;code&gt;-S&lt;/code&gt; the &lt;code&gt;-f&lt;/code&gt; is passed as an argument to LLM, and then the path to the file itself is passed after that:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;llm -f path/to/pelican.sh
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This takes advantage of &lt;a href="https://llm.datasette.io/en/stable/fragments.html" rel="nofollow"&gt;LLM's fragments mechanism&lt;/a&gt;. The argument to &lt;code&gt;-f&lt;/code&gt; is the path to a file, and the contents of that file will then be appended to the prompt.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;Adding tools&lt;/h2&gt;&lt;a id="user-content-adding-tools" class="anchor" aria-label="Permalink: Adding tools" href="#adding-tools"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;Scripts like this are a lot more interesting if they can execute tools.&lt;/p&gt;
&lt;p&gt;LLM has some &lt;a href="https://llm.datasette.io/en/stable/tools.html#default-tools" rel="nofollow"&gt;default tools&lt;/a&gt; which you can try out. Here's how to use the &lt;code&gt;llm_time&lt;/code&gt; tool which makes the current time available for the model to call:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#!/usr/bin/env -S llm -T llm_time -f
Write a haiku that mentions the exact current time
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I got (at 17:52 UTC):&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Whispers of the hour,&lt;br&gt;
Seventeen fifty-two chimes,&lt;br&gt;
Time flows ever on.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;Using templates&lt;/h2&gt;&lt;a id="user-content-using-templates" class="anchor" aria-label="Permalink: Using templates" href="#using-templates"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;LLM supports &lt;a href="https://llm.datasette.io/en/stable/templates.html" rel="nofollow"&gt;templates&lt;/a&gt; - YAML files that can mix a prompt, system prompt, model options, and tool definitions.&lt;/p&gt;
&lt;p&gt;These can be used with a shebang line by ending that line with a &lt;code&gt;-t&lt;/code&gt;, for example:&lt;/p&gt;
&lt;div class="highlight highlight-source-yaml"&gt;&lt;pre&gt;&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt;!/usr/bin/env -S llm -t&lt;/span&gt;
&lt;span class="pl-ent"&gt;prompt&lt;/span&gt;: &lt;span class="pl-s"&gt;Write a haiku&lt;/span&gt;
&lt;span class="pl-ent"&gt;system&lt;/span&gt;: &lt;span class="pl-s"&gt;Output Spanish&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;I got this:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Brisa en el bosque,&lt;br&gt;
hojas susurran sueños,&lt;br&gt;
paz en el silencio.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Templates can include parameters, for example:&lt;/p&gt;
&lt;div class="highlight highlight-source-yaml"&gt;&lt;pre&gt;&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt;!/usr/bin/env -S llm -t&lt;/span&gt;
&lt;span class="pl-ent"&gt;prompt&lt;/span&gt;: &lt;span class="pl-s"&gt;|&lt;/span&gt;
&lt;span class="pl-s"&gt;  Two line poem about $animal who lives in $place&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;This needs to be run like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;./poem.sh -p animal skunk -p place &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;hovercraft port&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;blockquote&gt;
&lt;p&gt;In hovercraft's hum, where the engines start,&lt;br&gt;
A skunk claims his kingdom, with pride and art.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;Templates with tools&lt;/h2&gt;&lt;a id="user-content-templates-with-tools" class="anchor" aria-label="Permalink: Templates with tools" href="#templates-with-tools"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;The most interesting way to use templates is with embedded tool functions. Here's a simple example of that, saved as &lt;code&gt;calc.sh&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight highlight-source-yaml"&gt;&lt;pre&gt;&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt;!/usr/bin/env -S llm -t&lt;/span&gt;
&lt;span class="pl-ent"&gt;model&lt;/span&gt;: &lt;span class="pl-s"&gt;gpt-5.4-mini&lt;/span&gt;
&lt;span class="pl-ent"&gt;system&lt;/span&gt;: &lt;span class="pl-s"&gt;|&lt;/span&gt;
&lt;span class="pl-s"&gt;  Use tools to run calculations&lt;/span&gt;
&lt;span class="pl-s"&gt;&lt;/span&gt;&lt;span class="pl-ent"&gt;functions&lt;/span&gt;: &lt;span class="pl-s"&gt;|&lt;/span&gt;
&lt;span class="pl-s"&gt;  def add(a: int, b: int) -&amp;gt; int:&lt;/span&gt;
&lt;span class="pl-s"&gt;      return a + b&lt;/span&gt;
&lt;span class="pl-s"&gt;  def multiply(a: int, b: int) -&amp;gt; int:&lt;/span&gt;
&lt;span class="pl-s"&gt;      return a * b&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Then:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;chmod 755 calc.sh
./calc.sh &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;what is 2344 * 5252 + 134&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; --td&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Which outputs (thanks to the &lt;code&gt;--td&lt;/code&gt; tool debug option):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Tool call: multiply({'a': 2344, 'b': 5252})
  12310688

Tool call: add({'a': 12310688, 'b': 134})
  12310822

2344 × 5252 + 134 = **12,310,822**
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here's a more complex example which defines a tool for searching my blog:&lt;/p&gt;
&lt;div class="highlight highlight-source-yaml"&gt;&lt;pre&gt;&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt;!/usr/bin/env -S llm -t&lt;/span&gt;
&lt;span class="pl-ent"&gt;model&lt;/span&gt;: &lt;span class="pl-s"&gt;gpt-5.5&lt;/span&gt;
&lt;span class="pl-ent"&gt;system&lt;/span&gt;: &lt;span class="pl-s"&gt;|&lt;/span&gt;
&lt;span class="pl-s"&gt;  You answer questions from Simon Willison's blog&lt;/span&gt;
&lt;span class="pl-s"&gt;&lt;/span&gt;&lt;span class="pl-ent"&gt;functions&lt;/span&gt;: &lt;span class="pl-s"&gt;|&lt;/span&gt;
&lt;span class="pl-s"&gt;  import httpx&lt;/span&gt;
&lt;span class="pl-s"&gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;  url = "https://datasette.simonwillison.net/simonwillisonblog.json"&lt;/span&gt;
&lt;span class="pl-s"&gt;  sql = """&lt;/span&gt;
&lt;span class="pl-s"&gt;  WITH results AS (&lt;/span&gt;
&lt;span class="pl-s"&gt;    SELECT 'entry' AS type, blog_entry.id AS id, blog_entry.slug AS slug,&lt;/span&gt;
&lt;span class="pl-s"&gt;           blog_entry.title AS title, blog_entry.created AS created,&lt;/span&gt;
&lt;span class="pl-s"&gt;           snippet(blog_entry_fts, -1, '&amp;lt;mark&amp;gt;', '&amp;lt;/mark&amp;gt;', '…', 100) AS snippet,&lt;/span&gt;
&lt;span class="pl-s"&gt;           blog_entry_fts.rank AS rank&lt;/span&gt;
&lt;span class="pl-s"&gt;    FROM blog_entry_fts JOIN blog_entry ON blog_entry.rowid = blog_entry_fts.rowid&lt;/span&gt;
&lt;span class="pl-s"&gt;    WHERE blog_entry_fts MATCH :q&lt;/span&gt;
&lt;span class="pl-s"&gt;    UNION ALL&lt;/span&gt;
&lt;span class="pl-s"&gt;    SELECT 'blogmark', blog_blogmark.id, blog_blogmark.slug,&lt;/span&gt;
&lt;span class="pl-s"&gt;           blog_blogmark.link_title, blog_blogmark.created,&lt;/span&gt;
&lt;span class="pl-s"&gt;           snippet(blog_blogmark_fts, -1, '&amp;lt;mark&amp;gt;', '&amp;lt;/mark&amp;gt;', '…', 100),&lt;/span&gt;
&lt;span class="pl-s"&gt;           blog_blogmark_fts.rank&lt;/span&gt;
&lt;span class="pl-s"&gt;    FROM blog_blogmark_fts JOIN blog_blogmark ON blog_blogmark.rowid = blog_blogmark_fts.rowid&lt;/span&gt;
&lt;span class="pl-s"&gt;    WHERE blog_blogmark_fts MATCH :q&lt;/span&gt;
&lt;span class="pl-s"&gt;    UNION ALL&lt;/span&gt;
&lt;span class="pl-s"&gt;    SELECT 'quotation', blog_quotation.id, blog_quotation.slug,&lt;/span&gt;
&lt;span class="pl-s"&gt;           blog_quotation.source, blog_quotation.created,&lt;/span&gt;
&lt;span class="pl-s"&gt;           snippet(blog_quotation_fts, -1, '&amp;lt;mark&amp;gt;', '&amp;lt;/mark&amp;gt;', '…', 100),&lt;/span&gt;
&lt;span class="pl-s"&gt;           blog_quotation_fts.rank&lt;/span&gt;
&lt;span class="pl-s"&gt;    FROM blog_quotation_fts JOIN blog_quotation ON blog_quotation.rowid = blog_quotation_fts.rowid&lt;/span&gt;
&lt;span class="pl-s"&gt;    WHERE blog_quotation_fts MATCH :q&lt;/span&gt;
&lt;span class="pl-s"&gt;    UNION ALL&lt;/span&gt;
&lt;span class="pl-s"&gt;    SELECT 'note', id, slug, title, created,&lt;/span&gt;
&lt;span class="pl-s"&gt;           -- crude snippet: ~100 chars around the first match&lt;/span&gt;
&lt;span class="pl-s"&gt;           -- because notes do not yet have FTS enabled&lt;/span&gt;
&lt;span class="pl-s"&gt;           '…' || substr(body, max(1, instr(lower(body), lower(:q)) - 40), 200) || '…',&lt;/span&gt;
&lt;span class="pl-s"&gt;           0.0  -- no real rank available&lt;/span&gt;
&lt;span class="pl-s"&gt;    FROM blog_note&lt;/span&gt;
&lt;span class="pl-s"&gt;    WHERE body LIKE '%' || :q || '%' OR title LIKE '%' || :q || '%'&lt;/span&gt;
&lt;span class="pl-s"&gt;  )&lt;/span&gt;
&lt;span class="pl-s"&gt;  SELECT snippet FROM results&lt;/span&gt;
&lt;span class="pl-s"&gt;  ORDER BY rank&lt;/span&gt;
&lt;span class="pl-s"&gt;  LIMIT 20&lt;/span&gt;
&lt;span class="pl-s"&gt;  """&lt;/span&gt;
&lt;span class="pl-s"&gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;  def search_blog(query: str) -&amp;gt; str:&lt;/span&gt;
&lt;span class="pl-s"&gt;      """Search Simon's blog"""&lt;/span&gt;
&lt;span class="pl-s"&gt;      return httpx.get(url, params={"sql": sql, "q": query}).text&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;This sets up a tool called &lt;code&gt;search_blog(query)&lt;/code&gt; which then executes an HTTP request against my &lt;a href="https://datasette.simonwillison.net/" rel="nofollow"&gt;datasette.simonwillison.net&lt;/a&gt; Datasette instance carrying a SQL query that searches various types of content.&lt;/p&gt;
&lt;p&gt;Result:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;./blog.sh &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Has Simon implemented GraphQL?&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Output:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Yes. Simon implemented GraphQL support for Datasette as a plugin called &lt;strong&gt;&lt;code&gt;datasette-graphql&lt;/code&gt;&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;He described it as “a plugin that adds GraphQL query support to Datasette,” and later as a “Datasette plugin providing an automatic GraphQL API for your SQLite databases.” It can expose Datasette tables through GraphQL, including nested fields based on foreign-key relationships.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Here's the &lt;a href="https://gist.github.com/simonw/551375ddec4dea1994bcaf299c473396"&gt;full log&lt;/a&gt; of that response showing the tool calls that were executed.&lt;/p&gt;
</content>
    <link href="https://til.simonwillison.net/llms/llm-shebang"/>
  </entry>
  <entry>
    <id>tag:til.simonwillison.net,2020-04-30:google-sheets_datasette-sql.md</id>
    <title>SQL functions in Google Sheets to fetch data from Datasette</title>
    <updated>2026-04-20T02:33:58+00:00</updated>
    <author>
      <name>Simon Willison</name>
      <uri>https://simonwillison.net/</uri>
    </author>
    <content type="html">&lt;p&gt;I've been experimenting with ways to fetch data from Datasette and display it in Google Sheets.&lt;/p&gt;
&lt;p&gt;I've found three patterns that work so far. &lt;code&gt;importdata()&lt;/code&gt; and "named functions" can only fetch from public Datasette instances. Apps Script can fetch from API key protected instances as well.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;Using IMPORTDATA()&lt;/h2&gt;&lt;a id="user-content-using-importdata" class="anchor" aria-label="Permalink: Using IMPORTDATA()" href="#using-importdata"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;The easiest way to get this up and running doesn't involve any custom sheets functions at all. The &lt;a href="https://support.google.com/docs/answer/3093335?hl=en" rel="nofollow"&gt;IMPORTDATA()&lt;/a&gt; default function can fetch any CSV data from a URL and load it into the sheet - and Datasette &lt;a href="https://docs.datasette.io/en/latest/csv_export.html" rel="nofollow"&gt;exports CSV&lt;/a&gt; by default.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://latest.datasette.io/fixtures/roadside_attractions.csv" rel="nofollow"&gt;https://latest.datasette.io/fixtures/roadside_attractions.csv&lt;/a&gt; - the CSV data for the &lt;a href="https://latest.datasette.io/fixtures/roadside_attractions" rel="nofollow"&gt;roadside_attractions&lt;/a&gt; table.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://latest.datasette.io/fixtures/-/query.csv?sql=select+pk%2C+name%2C+address%2C+url%2C+latitude%2C+longitude+from+roadside_attractions" rel="nofollow"&gt;https://latest.datasette.io/fixtures/-/query.csv?sql=select+pk%2C+name%2C+address%2C+url%2C+latitude%2C+longitude+from+roadside_attractions&lt;/a&gt; - a SQL export of a database query, in this case one that returns all rows from that table.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Either of these URLs can be used in a Google Sheets cell like this:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;=importdata("https://latest.datasette.io/fixtures/-/query.csv?sql=select+pk%2C+name%2C+address%2C+url%2C+latitude%2C+longitude+from+roadside_attractions&amp;amp;_size=max")&lt;/code&gt;&lt;/p&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;Using a named function&lt;/h2&gt;&lt;a id="user-content-using-a-named-function" class="anchor" aria-label="Permalink: Using a named function" href="#using-a-named-function"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;Ideally I'd like to use &lt;code&gt;=sql("SELECT ...")&lt;/code&gt; in my spreadsheet cells instead. Google Sheets lets you define new "named functions" on a per-sheet basis, which can use existing Sheets functions and formulas - including &lt;code&gt;importdata()&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Go to &lt;code&gt;Data -&amp;gt; Named functions&lt;/code&gt; and select "Add new function". Call it &lt;code&gt;SQL&lt;/code&gt; and add a single argument placeholder called &lt;code&gt;query&lt;/code&gt;, then set the following formula definition:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;=IMPORTDATA(
  "https://latest.datasette.io/fixtures/-/query.csv?sql=" &amp;amp;
  ENCODEURL(query)
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now you can use &lt;code&gt;=SQL("select * from roadside_attractions")&lt;/code&gt; in a cell to execute that SQL query and load in the CSV data:&lt;/p&gt;
&lt;p&gt;&lt;a target="_blank" rel="noopener noreferrer nofollow" href="https://raw.githubusercontent.com/simonw/til/main/google-sheets/named-function.jpg"&gt;&lt;img src="https://raw.githubusercontent.com/simonw/til/main/google-sheets/named-function.jpg" alt="Screenshot of Google Sheets. The spreadsheet displays data from a table, with the cell value set to =SQL(&amp;quot;select pk, name, address, url, latitude, longitude from roadside_attractions order by pk limit 101&amp;quot;). The &amp;quot;Edit named function&amp;quot; panel is visible on the right, where a function called SQL takes an argument placeholder &amp;quot;query&amp;quot; and has the IMPORTDATA formula definition shown above." style="max-width: 100%;"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;Using Apps Script&lt;/h2&gt;&lt;a id="user-content-using-apps-script" class="anchor" aria-label="Permalink: Using Apps Script" href="#using-apps-script"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;There's one big downside of &lt;code&gt;importdata()&lt;/code&gt; or a named function built on top of it: only unauthenticated URLs to CSV exports are supported. If your Datasette instance is protected by authentication and requires API keys to be sent as HTTP headers you will not be able to use them.&lt;/p&gt;
&lt;p&gt;(&lt;code&gt;importdata()&lt;/code&gt; can work fine here if the API key is a query string argument though. Here's &lt;a href="https://github.com/simonw/datasette-auth-tokens/blob/main/README.md#api-tokens-as-a-query-string-parameter"&gt;how to enable that&lt;/a&gt; using the &lt;code&gt;datasette-auth-tokens&lt;/code&gt; plugin.)&lt;/p&gt;
&lt;p&gt;&lt;a href="https://developers.google.com/apps-script" rel="nofollow"&gt;Apps Script&lt;/a&gt; lets you define custom server-side JavaScript functions which can then be called from a Google Sheets cell. These can be a lot more flexible, including sending API tokens in HTTP headers.&lt;/p&gt;
&lt;p&gt;To create an Apps Script for a spreadsheet, use "Extensions -&amp;gt; Apps Script". This will start you on a code editor with a &lt;code&gt;Code.gs&lt;/code&gt; file that you can edit. Here's a function definition for a &lt;code&gt;=datasette_sql(query)&lt;/code&gt; custom function:&lt;/p&gt;
&lt;div class="highlight highlight-source-js"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;function&lt;/span&gt; &lt;span class="pl-en"&gt;datasette_sql&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;query&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;var&lt;/span&gt; &lt;span class="pl-s1"&gt;baseUrl&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s"&gt;'https://latest.datasette.io/fixtures'&lt;/span&gt;
  &lt;span class="pl-k"&gt;var&lt;/span&gt; &lt;span class="pl-s1"&gt;token&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;// Strip a trailing slash so we control the join&lt;/span&gt;
  &lt;span class="pl-s1"&gt;baseUrl&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;baseUrl&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-pds"&gt;&lt;span class="pl-c1"&gt;/&lt;/span&gt;&lt;span class="pl-cce"&gt;\/&lt;/span&gt;&lt;span class="pl-c1"&gt;+&lt;/span&gt;&lt;span class="pl-cce"&gt;$&lt;/span&gt;&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-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;var&lt;/span&gt; &lt;span class="pl-s1"&gt;url&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;baseUrl&lt;/span&gt; &lt;span class="pl-c1"&gt;+&lt;/span&gt; &lt;span class="pl-s"&gt;"/-/query.json?sql="&lt;/span&gt; &lt;span class="pl-c1"&gt;+&lt;/span&gt; &lt;span class="pl-en"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;query&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;var&lt;/span&gt; &lt;span class="pl-s1"&gt;options&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-c1"&gt;muteHttpExceptions&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;if&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;token&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;options&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;headers&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-c1"&gt;Authorization&lt;/span&gt;: &lt;span class="pl-s"&gt;"Bearer "&lt;/span&gt; &lt;span class="pl-c1"&gt;+&lt;/span&gt; &lt;span class="pl-s1"&gt;token&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;var&lt;/span&gt; &lt;span class="pl-s1"&gt;response&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-v"&gt;UrlFetchApp&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;fetch&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;url&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-s1"&gt;options&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;var&lt;/span&gt; &lt;span class="pl-s1"&gt;json&lt;/span&gt; &lt;span class="pl-c1"&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;response&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;getContentText&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;if&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-s1"&gt;json&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;ok&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;throw&lt;/span&gt; &lt;span class="pl-k"&gt;new&lt;/span&gt; &lt;span class="pl-v"&gt;Error&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;json&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-c1"&gt;||&lt;/span&gt; &lt;span class="pl-s"&gt;"Query failed"&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;var&lt;/span&gt; &lt;span class="pl-s1"&gt;rows&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;json&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;rows&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-c1"&gt;!&lt;/span&gt;&lt;span class="pl-s1"&gt;rows&lt;/span&gt; &lt;span class="pl-c1"&gt;||&lt;/span&gt; &lt;span class="pl-s1"&gt;rows&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;length&lt;/span&gt; &lt;span class="pl-c1"&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-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-s"&gt;"No results"&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;var&lt;/span&gt; &lt;span class="pl-s1"&gt;cols&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-v"&gt;Object&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;keys&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;rows&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-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-k"&gt;var&lt;/span&gt; &lt;span class="pl-s1"&gt;result&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;cols&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;for&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-k"&gt;var&lt;/span&gt; &lt;span class="pl-s1"&gt;i&lt;/span&gt; &lt;span class="pl-c1"&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-s1"&gt;i&lt;/span&gt; &lt;span class="pl-c1"&gt;&amp;lt;&lt;/span&gt; &lt;span class="pl-s1"&gt;rows&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;length&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt; &lt;span class="pl-s1"&gt;i&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-k"&gt;var&lt;/span&gt; &lt;span class="pl-s1"&gt;row&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;for&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-k"&gt;var&lt;/span&gt; &lt;span class="pl-s1"&gt;j&lt;/span&gt; &lt;span class="pl-c1"&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-s1"&gt;j&lt;/span&gt; &lt;span class="pl-c1"&gt;&amp;lt;&lt;/span&gt; &lt;span class="pl-s1"&gt;cols&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;length&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt; &lt;span class="pl-s1"&gt;j&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-k"&gt;var&lt;/span&gt; &lt;span class="pl-s1"&gt;val&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;rows&lt;/span&gt;&lt;span class="pl-kos"&gt;[&lt;/span&gt;&lt;span class="pl-s1"&gt;i&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;cols&lt;/span&gt;&lt;span class="pl-kos"&gt;[&lt;/span&gt;&lt;span class="pl-s1"&gt;j&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;row&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;val&lt;/span&gt; &lt;span class="pl-c1"&gt;===&lt;/span&gt; &lt;span class="pl-c1"&gt;null&lt;/span&gt; ? &lt;span class="pl-s"&gt;""&lt;/span&gt; : &lt;span class="pl-s1"&gt;val&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;result&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;row&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-s1"&gt;result&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;You can set the base URL and an optional API token in variables at the top of the script.&lt;/p&gt;
&lt;p&gt;&lt;a target="_blank" rel="noopener noreferrer nofollow" href="https://raw.githubusercontent.com/simonw/til/main/google-sheets/apps-script-editor.jpg"&gt;&lt;img src="https://raw.githubusercontent.com/simonw/til/main/google-sheets/apps-script-editor.jpg" alt="Apps Script editor UI - lots of menu items, a blue Deploy button and the source code for the Code.gs file." style="max-width: 100%;"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;You can ignore that "Deploy" button entirely, it's not necessary for custom functions for sheets. I had to hit the &lt;code&gt;Command+S&lt;/code&gt; key combination to save my changes - confusingly I could not find a "save" button in the editor UI.&lt;/p&gt;
&lt;p&gt;Apps Script has a script and document properties mechanism which theoretically could be used to keep secret values separate from that code, but I wasn't able to get that to work without confusing permission dialogs popping up.&lt;/p&gt;
&lt;p&gt;As far as I can tell users who have "view" permission but not "edit" permission on the spreadsheet are unable to view the source code, so it should be safe to keep read-only API tokens in the source code even for shared spreadsheets.&lt;/p&gt;
&lt;p&gt;I've prepared &lt;a href="https://docs.google.com/spreadsheets/d/14lRV2-AeBmjI3lJbl2apwfC_ncXqL0uSV68lmtzUI7I/edit?gid=0#gid=0" rel="nofollow"&gt;this demo sheet&lt;/a&gt; showing all three of the above solutions - &lt;code&gt;importdata()&lt;/code&gt;, a named &lt;code&gt;sql()&lt;/code&gt; function and a &lt;code&gt;datasette_sql()&lt;/code&gt; function defined using Apps Script.&lt;/p&gt;
</content>
    <link href="https://til.simonwillison.net/google-sheets/datasette-sql"/>
  </entry>
  <entry>
    <id>tag:til.simonwillison.net,2020-04-30:llms_openclaw-docker.md</id>
    <title>Running OpenClaw in Docker</title>
    <updated>2026-02-01T23:59:07+00:00</updated>
    <author>
      <name>Simon Willison</name>
      <uri>https://simonwillison.net/</uri>
    </author>
    <content type="html">&lt;p&gt;I'm not brave enough to run &lt;a href="https://openclaw.ai/" rel="nofollow"&gt;OpenClaw&lt;/a&gt; (aka Clawdbot aka Moltbot) directly on my Mac, so I decided to try running it in a Docker instead container.&lt;/p&gt;
&lt;p&gt;OpenClaw has Docker support out of the box, described &lt;a href="https://docs.openclaw.ai/install/docker" rel="nofollow"&gt;on this page&lt;/a&gt; of their documentation. Here's what worked for me.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;Use their Docker Compose configuration&lt;/h2&gt;&lt;a id="user-content-use-their-docker-compose-configuration" class="anchor" aria-label="Permalink: Use their Docker Compose configuration" href="#use-their-docker-compose-configuration"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;First, clone their GitHub repository:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;git clone https://github.com/openclaw/openclaw&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;It contains a script for running OpenClaw in Docker called &lt;a href="https://github.com/openclaw/openclaw/blob/main/docker-setup.sh"&gt;docker-setup.sh&lt;/a&gt; which in turn uses Docker Compose and their &lt;a href="https://github.com/openclaw/openclaw/blob/main/docker-compose.yml"&gt;docker-compose.yml&lt;/a&gt; file.&lt;/p&gt;
&lt;p&gt;The script will create two folders directly on your Mac which will then be mounted as volumes in the Docker container:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;~/.openclaw&lt;/code&gt; is the configuration directory. This will eventually contain OpenClaw memory, configuration, third party API keys, etc.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;~/openclaw/workspace&lt;/code&gt; is the workspace directory full of files that are directly available to the agent as it runs inside the container. Files the agent creates will be saved here too.&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;Answering all of those questions&lt;/h2&gt;&lt;a id="user-content-answering-all-of-those-questions" class="anchor" aria-label="Permalink: Answering all of those questions" href="#answering-all-of-those-questions"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;On first run OpenClaw asks you a &lt;em&gt;lot&lt;/em&gt; of questions. Most of these are reasonably obvious but I still had to start over a couple of times to get everything right. Some that I found tricky:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Onboarding mode: manual&lt;/li&gt;
&lt;li&gt;What do you want to set up?: Local gateway (this machine)&lt;/li&gt;
&lt;li&gt;Model provider. I decided to go for OpenAI Codex with ChatGPT OAuth, which then allowed me to authenticate against ChatGPT to spend tokens already covered by my $20/month OpenAI subscription. I did this because I've heard that OpenClaw can spend a &lt;em&gt;lot&lt;/em&gt; of tokens on API plans, and using ChatGPT put an easy upper limit on how much it could spend. When you opt for this OpenClaw gives you a URL to open in your browser which redirects back to a non-running &lt;code&gt;localhost&lt;/code&gt; service and displays an error message - you then copy and paste that &lt;code&gt;localhost&lt;/code&gt; URL back into OpenClaw to complete the authentication. Here's &lt;a href="https://gisthost.github.io/?6f7671b0c90839f01f63c5a3850656b7" rel="nofollow"&gt;what that looks like&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Tailscale: I tried to configure this the first time and it resulted in a machine I couldn't use, so the second time I said "no".&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Once it's up and running you can run:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker ps
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;To see the container. Mine is running the image &lt;code&gt;openclaw:local&lt;/code&gt; and has a container name &lt;code&gt;openclaw-openclaw-gateway-1&lt;/code&gt;.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;Running administrative commands&lt;/h2&gt;&lt;a id="user-content-running-administrative-commands" class="anchor" aria-label="Permalink: Running administrative commands" href="#running-administrative-commands"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;The other container provided by Docker Compose is called &lt;code&gt;openclaw-cli&lt;/code&gt; and can be used to run the &lt;a href="https://docs.openclaw.ai/cli" rel="nofollow"&gt;openclaw CLI commands&lt;/a&gt; for managing the instance.&lt;/p&gt;
&lt;p&gt;This works for that, but you &lt;em&gt;must&lt;/em&gt; run it in the same folder as that &lt;code&gt;docker-compose.yml&lt;/code&gt; file.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker compose run --rm openclaw-cli status
&lt;/code&gt;&lt;/pre&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;Setting up a Telegram bot&lt;/h2&gt;&lt;a id="user-content-setting-up-a-telegram-bot" class="anchor" aria-label="Permalink: Setting up a Telegram bot" href="#setting-up-a-telegram-bot"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;OpenClaw can communicate via a number of different messaging platforms, including WhatsApp and iMessage and Telegram and Slack and Discord. This means you can control the instance in your container directly from your phone.&lt;/p&gt;
&lt;p&gt;I decided Telegram looked like the least hassle to set up.&lt;/p&gt;
&lt;p&gt;You'll need a Telegram account. Then create a new bot by chatting to &lt;a href="https://t.me/BotFather" rel="nofollow"&gt;@BotFather&lt;/a&gt; on Telegram.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Start a chat with @BotFather&lt;/li&gt;
&lt;li&gt;Send the command &lt;code&gt;/newbot&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Follow the instructions to name your bot and get a token&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;That token can then be provided to OpenClaw as part of the initial setup wizard.&lt;/p&gt;
&lt;p&gt;There's one remaining step: you have to &lt;em&gt;pair&lt;/em&gt; your Telegram account with your new bot and your OpenClaw instance.&lt;/p&gt;
&lt;p&gt;OpenClaw will send you a message via Telegram with the pairing code, then run this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker compose run --rm openclaw-cli pairing approve telegram &amp;lt;CODE&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;At this point you should be able to message your bot directly from Telegram on your phone!&lt;/p&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;Accessing the web UI&lt;/h2&gt;&lt;a id="user-content-accessing-the-web-ui" class="anchor" aria-label="Permalink: Accessing the web UI" href="#accessing-the-web-ui"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;OpenClaw runs a default web UI on port 18789. If you access this directly at &lt;code&gt;http://localhost:18789&lt;/code&gt; you'll see an error telling you that you need to authenticate first.&lt;/p&gt;
&lt;p&gt;To do that you need a special &lt;code&gt;?token=...&lt;/code&gt; URL parameter. This may have been displayed during setup, but if you lose it you can get a new one like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker compose run --rm openclaw-cli dashboard --no-open
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Follew the URL that spits out to access the interface.&lt;/p&gt;
&lt;p&gt;Sometimes that's not enough either - you may see this error:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;disconnected (1008): pairing required
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For some reason the &lt;code&gt;openclaw-cli&lt;/code&gt; container didn't work for me here, but this alternative way of running the &lt;code&gt;openclaw&lt;/code&gt; commands did:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker compose exec openclaw-gateway \
  node dist/index.js devices list
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That shows a list of pairings, hopefully including a request that is not yet approved like this one:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Pending (1)
┌──────────────────────────────────────┬───────────────────────────────────┬──────────┬────────────┬────────┬────────┐
│ Request                              │ Device                            │ Role     │ IP         │ Age    │ Flags  │
├──────────────────────────────────────┼───────────────────────────────────┼──────────┼────────────┼────────┼────────┤
│ 6f9db1bd-a1cc-4d3f-b643-2c195262464e │ 8b7bbf4f69633058dc3beee8a56adbccf │ operator │ 172.18.0.1 │ 2m ago │        │
│                                      │ aafc8be8058bea8a06be1cb7bfad9b3   │          │            │        │        │
└──────────────────────────────────────┴───────────────────────────────────┴──────────┴────────────┴────────┴────────┘
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;To approve that, run this command:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker compose exec openclaw-gateway \
  node dist/index.js devices approve \
  6f9db1bd-a1cc-4d3f-b643-2c195262464e
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The dashboard UI looks like this, and has a whole load of different debugging tools plus a web chat interface:&lt;/p&gt;
&lt;p&gt;&lt;a target="_blank" rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/f8ac37409eae2ba20f461eff3eb91b5756b6f75a207174e5b0681dd9dc2e76c6/68747470733a2f2f7374617469632e73696d6f6e77696c6c69736f6e2e6e65742f7374617469632f323032362f6f70656e636c61772d7765622d75692e6a7067"&gt;&lt;img src="https://camo.githubusercontent.com/f8ac37409eae2ba20f461eff3eb91b5756b6f75a207174e5b0681dd9dc2e76c6/68747470733a2f2f7374617469632e73696d6f6e77696c6c69736f6e2e6e65742f7374617469632f323032362f6f70656e636c61772d7765622d75692e6a7067" alt="Screenshot of the OpenClaw Gateway Dashboard web interface. Header shows &amp;quot;OpenCLAW GATEWAY DASHBOARD&amp;quot; with a green &amp;quot;Health OK&amp;quot; indicator. Left sidebar contains navigation sections: Chat (Chat highlighted), Control (Overview, Channels, Instances, Sessions, Cron Jobs), Agent (Skills, Nodes), Settings (Config, Debug, Logs), and Resources (Docs). Main content area displays &amp;quot;Chat&amp;quot; with subtitle &amp;quot;Direct gateway chat session for quick interventions.&amp;quot; and &amp;quot;telegram:6580064359&amp;quot; identifier. A user message at 4:08 PM reads &amp;quot;Show me a detailed list of all your available configured tools&amp;quot;. The assistant response states: &amp;quot;Here's the full list of tools I have available in this OpenClaw session (as configured). These are the only ones I can call programmatically:&amp;quot; followed by categorized tools: &amp;quot;File &amp;amp; workspace&amp;quot; (read — Read a file (text or image). Supports offset/limit for large files; write — Create/overwrite a file (creates parent dirs); edit — Precise in-place edit by exact string replacement), &amp;quot;Shell / processes&amp;quot; (exec — Run a shell command (optionally PTY, backgrounding, timeouts); process — Manage running exec sessions (list/poll/log/write/kill/etc.)), &amp;quot;Web&amp;quot; (web_search — Search the web (Brave Search API); web_fetch — Fetch a URL and extract readable content (markdown/text); browser — Control a browser (open/navigate/snapshot/screenshot/act/etc.)), &amp;quot;UI / rendering&amp;quot; (canvas — Present/eval/snapshot a Canvas surface (for node canvases/UI rendering)), and &amp;quot;Devices / nodes&amp;quot; (cut off). Bottom shows message input with placeholder &amp;quot;Message (↵ to send, Shift+↵ for line breaks, paste images)&amp;quot; and &amp;quot;New session&amp;quot; and coral &amp;quot;Send&amp;quot; buttons." data-canonical-src="https://static.simonwillison.net/static/2026/openclaw-web-ui.jpg" style="max-width: 100%;"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;Running commands as root&lt;/h2&gt;&lt;a id="user-content-running-commands-as-root" class="anchor" aria-label="Permalink: Running commands as root" href="#running-commands-as-root"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;I found myself wanting to install some extra packages, but the OpenClaw bot itself runs as a user without &lt;code&gt;sudo&lt;/code&gt; access (probably for the best!)&lt;/p&gt;
&lt;p&gt;You can access a Bash shell as root like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker compose exec -u root openclaw-gateway bash
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I installed extra packages there like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;apt-get update &amp;amp;&amp;amp; apt-get install -y ripgrep
&lt;/code&gt;&lt;/pre&gt;
</content>
    <link href="https://til.simonwillison.net/llms/openclaw-docker"/>
  </entry>
  <entry>
    <id>tag:til.simonwillison.net,2020-04-30:cloudflare_response-header-transform-rule.md</id>
    <title>Cloudflare response header transform rules</title>
    <updated>2026-01-23T15:08:25+00:00</updated>
    <author>
      <name>Simon Willison</name>
      <uri>https://simonwillison.net/</uri>
    </author>
    <content type="html">&lt;p&gt;I serve Python files from my &lt;code&gt;tools.simonwillison.net&lt;/code&gt; subdomain, which is a GitHub Pages site that's served via Cloudflare. For example:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;https://tools.simonwillison.net/python/q3_tts.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is meant to be used with &lt;code&gt;uv&lt;/code&gt; &lt;a href="https://simonwillison.net/2026/Jan/22/qwen3-tts/" rel="nofollow"&gt;as described here&lt;/a&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;uv run https://tools.simonwillison.net/python/q3_tts.py \
  'I am a pirate, give me your gold!' \
  -i 'gruff voice' -o pirate.wav
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;By default files with &lt;code&gt;.py&lt;/code&gt; extensions are served with a &lt;code&gt;content-type: application/octet-stream&lt;/code&gt; header. This means browsers will download them rather than displaying them directly to the user.&lt;/p&gt;
&lt;p&gt;For code that you're going to execute on your machine it's nice to be able to preview it in your browser first!&lt;/p&gt;
&lt;p&gt;Here's that default &lt;code&gt;content-type&lt;/code&gt; header:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;~ % curl -I 'https://tools.simonwillison.net/python/q3_tts.py'
HTTP/2 200 
date: Fri, 23 Jan 2026 14:52:54 GMT
content-type: application/octet-stream
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I can't control how GitHub Pages serves files, but since the site is behind Cloudflare I can fix the problem there instead.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;Navigating the Cloudflare dashboard&lt;/h2&gt;&lt;a id="user-content-navigating-the-cloudflare-dashboard" class="anchor" aria-label="Permalink: Navigating the Cloudflare dashboard" href="#navigating-the-cloudflare-dashboard"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;The area to look for here is called "Rules". Within that area the "Create rule" button has an option to create a "Response Header Transform Rule":&lt;/p&gt;
&lt;p&gt;&lt;a target="_blank" rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/90c6238f4affbc2d454f544ee07e19c6ee5e14f305b37d984de85bc6ced34fd3/68747470733a2f2f7374617469632e73696d6f6e77696c6c69736f6e2e6e65742f7374617469632f323032362f636c6f7564666c6172652d72756c65732d6d656e752e706e67"&gt;&lt;img src="https://camo.githubusercontent.com/90c6238f4affbc2d454f544ee07e19c6ee5e14f305b37d984de85bc6ced34fd3/68747470733a2f2f7374617469632e73696d6f6e77696c6c69736f6e2e6e65742f7374617469632f323032362f636c6f7564666c6172652d72756c65732d6d656e752e706e67" alt="Rules Overview page. The Create rule menu is open showing a list of different rule types, including Redirect Rule, URL Rewrite Rule, Request Header Transform rule and Response Header Transform Rule." data-canonical-src="https://static.simonwillison.net/static/2026/cloudflare-rules-menu.png" style="max-width: 100%;"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;I created a rule that looks like this:&lt;/p&gt;
&lt;p&gt;&lt;a target="_blank" rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/8da058699a8d5d20ba5f0d4bad01779d4e08dea774c2751639057f5cf1ed50ff/68747470733a2f2f7374617469632e73696d6f6e77696c6c69736f6e2e6e65742f7374617469632f323032362f656469742d687474702d726573706f6e73652d6865616465722e706e67"&gt;&lt;img src="https://camo.githubusercontent.com/8da058699a8d5d20ba5f0d4bad01779d4e08dea774c2751639057f5cf1ed50ff/68747470733a2f2f7374617469632e73696d6f6e77696c6c69736f6e2e6e65742f7374617469632f323032362f656469742d687474702d726573706f6e73652d6865616465722e706e67" alt="Edit HTTP Response Header Transform Rule form - the rule name is set to tools.simonwillison.net .py files to text/plain, it has a Custom filter expression for the incoming requests matching Hostname equals tools.simonwillison.net and URI Path ends with .py - this shows an expression preview of http.host eq &amp;quot;tools.simonwillison.net&amp;quot; and ends_with(http.request.uri.path, &amp;quot;&amp;quot;py&amp;quot;)) - then it uses Set static of content-type to text/plain; charset=utf-8" data-canonical-src="https://static.simonwillison.net/static/2026/edit-http-response-header.png" style="max-width: 100%;"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;The response header transform rule&lt;/h2&gt;&lt;a id="user-content-the-response-header-transform-rule" class="anchor" aria-label="Permalink: The response header transform rule" href="#the-response-header-transform-rule"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;My new rule has the following settings:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Rule name&lt;/strong&gt;: &lt;code&gt;tools.simonwillison.net .py files to text/plain&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When incoming requests match&lt;/strong&gt;: Custom filter expression&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Filter expression&lt;/strong&gt;:
&lt;ul&gt;
&lt;li&gt;Hostname equals &lt;code&gt;tools.simonwillison.net&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;AND URI Path ends with &lt;code&gt;.py&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Then&lt;/strong&gt;:
&lt;ul&gt;
&lt;li&gt;Set static &lt;code&gt;content-type&lt;/code&gt; to &lt;code&gt;text/plain; charset=utf-8&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I clicked 'Deploy' and within a few seconds saw this result:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;~ % curl -I 'https://tools.simonwillison.net/python/q3_tts.py'
HTTP/2 200 
date: Fri, 23 Jan 2026 14:52:58 GMT
content-type: text/plain; charset=utf-8
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And now &lt;a href="https://tools.simonwillison.net/python/q3_tts.py" rel="nofollow"&gt;https://tools.simonwillison.net/python/q3_tts.py&lt;/a&gt; displays the Python code directly in my browser.&lt;/p&gt;
</content>
    <link href="https://til.simonwillison.net/cloudflare/response-header-transform-rule"/>
  </entry>
  <entry>
    <id>tag:til.simonwillison.net,2020-04-30:claude-code_preview-github-pages.md</id>
    <title>Previewing Claude Code for web branches with GitHub Pages</title>
    <updated>2026-01-22T16:43:41+00:00</updated>
    <author>
      <name>Simon Willison</name>
      <uri>https://simonwillison.net/</uri>
    </author>
    <content type="html">&lt;p&gt;I'm a big user of &lt;a href="https://code.claude.com/docs/en/claude-code-on-the-web" rel="nofollow"&gt;Claude Code on the web&lt;/a&gt;, Anthropic's poorly named cloud-based version of Claude Code which can be driven via the web or their native mobile and desktop applications.&lt;/p&gt;
&lt;p&gt;I mostly use it through the Claude iPhone app.&lt;/p&gt;
&lt;p&gt;The biggest downside of this way of working is that, beyond CLI tools or code libraries, it's difficult to preview its work while it is iterating on the code.&lt;/p&gt;
&lt;p&gt;I've started using GitHub Pages against private repositories to work around this limitation recently  - at least for HTML projects - and it's working really well.&lt;/p&gt;
&lt;p&gt;Here's how I'll spin up a new experimental prototype such that I can preview it on my phone.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Create a new private repository at &lt;a href="https://github.com/new"&gt;https://github.com/new&lt;/a&gt; - include an empty README here to ensure the repo has been initialized.&lt;/li&gt;
&lt;li&gt;Start a task in Claude Code against that repo - you may need to restart the app to ensure it picks up the newly created repo.&lt;/li&gt;
&lt;li&gt;Tell Claude Code what to build, using a variation on "self-contained HTML file, vanilla JavaScript, no build step, load any dependencies from a CDN" - this ensures that the code it writes will run directly in a browser without needing to mess with npm&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Claude Code will get to work, and after a few minutes will create a branch in your repository. Here's the clever bit:&lt;/p&gt;
&lt;ol start="4"&gt;
&lt;li&gt;Navigate to the Pages area of your repository settings, make sure "Deploy from a branch" is selected (the default) and then open the branch picker and select the &lt;code&gt;claude/...&lt;/code&gt; branch that Claude Code just created&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Wait about a minute for the deploy to complete and your new preview should become available at this (secret) URL:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;https://your-username.github.io/your-repo/index.html
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This will serve the full static contents of your repository so you can also reuse the same private repository for other projects like this in the future, though you'll have to manually select the new branch that Claude creates each time.&lt;/p&gt;
&lt;p&gt;Crucially, your Claude Code for web session is still running. You can request changes from Claude - and even drop in screenshots of what it's built so far - and Claude will make changes and then push them to the existing branch, which means they'll be available in your preview shortly afterwards.&lt;/p&gt;
&lt;p&gt;I've not found a limit on how long these Claude sessions stay available - it's possible you could keep that session and PR running indefinitely, continually pushing changes to your deployed environment for many weeks to come.&lt;/p&gt;
&lt;p&gt;When you land the PR don't forget to update the Pages settings to point back to the main branch.&lt;/p&gt;
&lt;p&gt;Unlike Claude's own Artifacts feature there are no  CSP restrictions on what apps deployed to GitHub Pages can do, which means they can interact directly with JSON APIs hosted on other domains.&lt;/p&gt;
&lt;p&gt;There are other good options for deploying previews of branches - I've used &lt;a href="https://pages.cloudflare.com/" rel="nofollow"&gt;Cloudflare Pages&lt;/a&gt; for this in the past - but it's nice to be able to get this done using GitHub alone.&lt;/p&gt;
</content>
    <link href="https://til.simonwillison.net/claude-code/preview-github-pages"/>
  </entry>
  <entry>
    <id>tag:til.simonwillison.net,2020-04-30:neon_neon-1.md</id>
    <title>Taking Neon I at the Crucible</title>
    <updated>2026-01-11T17:34:29+00:00</updated>
    <author>
      <name>Simon Willison</name>
      <uri>https://simonwillison.net/</uri>
    </author>
    <content type="html">&lt;p&gt;I took the Neon I intensive week-long evening class at &lt;a href="https://www.thecrucible.org/" rel="nofollow"&gt;the Crucible&lt;/a&gt; in Oakland, with teachers Dan Kuppe and Kat. I learned to make a neon sign! It's still awaiting final infusion of gas, but I'll share photos here once it's finished.&lt;/p&gt;
&lt;p&gt;Here's much of what I learned, at least the parts that can be translated into text.&lt;/p&gt;
&lt;p&gt;At the Neon 1 level, the craft is almost entirely around making shapes out of glass. The starting point is lengths of glass tubes - I think ours were 10mm in diameter and 4 feet long.&lt;/p&gt;
&lt;p&gt;The way you make those shapes is by heating up and bending that glass, using a variety of gas torches.&lt;/p&gt;
&lt;p&gt;This can be done mostly by naked hand - protective gloves weren’t necessary for the bends that we made.&lt;/p&gt;
&lt;p&gt;You hold the glass over the gas flame and rotate it constantly to ensure it is equally warmed. Then after som time - usually 15-30s - the glass becomes malleable enough that you can bend it.&lt;/p&gt;
&lt;p&gt;The key trick to getting the right bend is heating the right section of the glass. For a tight corner you heat just the very short segment that will form the corner. For a long curve you heat the section that will be part of the curve.&lt;/p&gt;
&lt;p&gt;&lt;a target="_blank" rel="noopener noreferrer" href="https://github.com/user-attachments/assets/1ffa89e0-8b85-49c1-8e12-a0ca68fc528e"&gt;&lt;img width="1024" height="768" alt="Our instructor Dan with an elaborate shaped glass tube heating a lengthy portion of it on a long gas burner" src="https://github.com/user-attachments/assets/1ffa89e0-8b85-49c1-8e12-a0ca68fc528e" style="max-width: 100%; height: auto; max-height: 768px;"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Bending the glass upwards is more effective than bending it downwards - you can use gravity to help, especially useful for larger curves where the glass will naturally sag once it hits the right temperature.&lt;/p&gt;
&lt;p&gt;For corners you need to ensure the glass both stays sealed but has a hole that the neon can flow through later. You can help achieve this by blowing air into the glass tube, using a stopper at one end and a rubber hose attached to a mouthpiece at the other.&lt;/p&gt;
&lt;p&gt;In a Jedi-make-their-own-lightsabers move, we created our own mouthpieces for the hose by putting a 90 degree bend in a thin tube and then heating and flaring out the end using a torch and a file. I dropped one of these and had to make another one.&lt;/p&gt;
&lt;p&gt;It is crucial you don’t blow into the glass while it is in the flame because it is likely to burst! Instead you blow when the glass has just come out of the flame but is still hot - this can visibly affect the shape of the bend you are making. The goal is to have as close to a round hole in the center of the pipe as possible, although even a misinformed oval will still be OK as long as air (and eventually neon) can flow through it.&lt;/p&gt;
&lt;p&gt;The best way to achieve a specific shape is to draw it precisely on paper, then cover that paper with a wire mesh so you can drop the glass onto it while it’s still malleable and form it to match the illustration. If you forget the mesh (I did that once) your paper will catch fire!&lt;/p&gt;
&lt;p&gt;A tricky skill is planning out the sequence of bends that you will perform on a piece of glass. I haven’t developed a good instinct for this yet. You need to consider the rotation of the glass on the burner - if you create the wrong corners too early you won’t be able to safely position it on the gas such that you can rotate it without burning your hand or getting the glass caught on the apparatus.&lt;/p&gt;
&lt;p&gt;Cutting the glass is quite easy: score one edge with a file and then tap it firmly such that it breaks. Some of my breaks were clean and others were jagged, and I’m not sure why but people with better technique consistently achieved clean breaks.&lt;/p&gt;
&lt;p&gt;By far the hardest technique (for me at least) was &lt;strong&gt;welding&lt;/strong&gt;. This is when you have two glass tubes and you wish to join them together as if they are one - important for combining pieces of the right shape, fixing breaks and attaching the electrodes at the end (more on that below).&lt;/p&gt;
&lt;p&gt;To weld you wedge one glass piece firmly on a table with weights such that the end you attach to protrudes over the edge. Then you align the other glass piece with it and use a hand-held torch to rotate around and heat the ends of the glass tubes - keeping them about half a centimeter away from each other.&lt;/p&gt;
&lt;p&gt;&lt;a target="_blank" rel="noopener noreferrer" href="https://github.com/user-attachments/assets/e3ce2397-6bce-42a8-87d7-0a05e18e3af5"&gt;&lt;img width="1024" height="768" alt="Two glass tubes being heated by a hand torch" src="https://github.com/user-attachments/assets/e3ce2397-6bce-42a8-87d7-0a05e18e3af5" style="max-width: 100%; height: auto; max-height: 768px;"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Once they are hot enough you kiss them together, then continue to heat the junction to get it malleable. Then you remove the heat and blow down the tube while stretching it out a little to try to get a clean merged section - ideally looking as close to a regular tube of glass as possible.&lt;/p&gt;
&lt;p&gt;Doing this requires depth perception and a lot of practice. I only have one working eye, which usually doesn’t affect me much but turns out to make welding incredibly hard do to the need to align two glass tubes and a hand torch all at the same time.&lt;/p&gt;
&lt;p&gt;The final step is to attach electrodes to the ends of your glass segments. These come as short glass tubes with wires coming out of them, which you weld onto the ends.&lt;/p&gt;
&lt;p&gt;One of those electrodes will have a thin glass tube protruding from the end. This is used later on to insert the neon (or argon) gas as the final part of the process.&lt;/p&gt;
&lt;p&gt;A few more miscellaneous tips I picked up:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The final piece should not have two glass tubes touching each other as this can cause arcs to interfere with each other. A thin layer of silicon between the tubes fixes this.&lt;/li&gt;
&lt;li&gt;Neon comes out orange, argon comes out purple. There are colored glasses that can achieve other colors but we didn’t use these in our beginners’ class.&lt;/li&gt;
&lt;li&gt;It’s important to wrap your rubber hose around your arm in a way that avoids it coming into contact with the burner or hot glass.&lt;/li&gt;
&lt;li&gt;Use wooden paddles to help flatten glass shapes while they are still warm. Using cold metal can cause the glass to weaken or fracture.&lt;/li&gt;
&lt;/ul&gt;
</content>
    <link href="https://til.simonwillison.net/neon/neon-1"/>
  </entry>
  <entry>
    <id>tag:til.simonwillison.net,2020-04-30:github_software-archive-recovery.md</id>
    <title>Downloading archived Git repositories from archive.softwareheritage.org</title>
    <updated>2025-12-30T23:43:48+00:00</updated>
    <author>
      <name>Simon Willison</name>
      <uri>https://simonwillison.net/</uri>
    </author>
    <content type="html">&lt;p&gt;Last February I &lt;a href="https://simonwillison.net/2025/Feb/7/sqlite-s3vfs/" rel="nofollow"&gt;blogged about&lt;/a&gt; a neat script called &lt;code&gt;sqlite-s3vfs&lt;/code&gt; which was released as MIT licensed open source by the UK government's Department for Business and Trade.&lt;/p&gt;
&lt;p&gt;I went looking for it today and found that the &lt;a href="https://github.com/uktrade/sqlite-s3vfs"&gt;github.com/uktrade/sqlite-s3vfs&lt;/a&gt; repository is now a 404.&lt;/p&gt;
&lt;p&gt;Since this is taxpayer-funded software and was released MIT I felt a moral obligation to try and restore the repository!&lt;/p&gt;
&lt;p&gt;My restored copy can be found at &lt;a href="https://github.com/simonw/sqlite-s3vfs"&gt;github.com/simonw/sqlite-s3vfs&lt;/a&gt;. Here's how I made it.&lt;/p&gt;
&lt;p&gt;Claude suggested trying the &lt;a href="https://archive.softwareheritage.org/" rel="nofollow"&gt;Software Heritage archive&lt;/a&gt; and sure enough a search for &lt;code&gt;https://github.com/uktrade/sqlite-s3vfs&lt;/code&gt; on their site resolved to &lt;a href="https://archive.softwareheritage.org/browse/origin/directory/?origin_url=https://github.com/uktrade/sqlite-s3vfs" rel="nofollow"&gt;this archived page&lt;/a&gt;, which appeared to have a full copy of the repo.&lt;/p&gt;
&lt;p&gt;Downloading a snapshot of the most recent state was easy enough, but I wanted the full Git history. I stumbled across &lt;a href="https://news.ycombinator.com/item?id=37516523#37517378" rel="nofollow"&gt;this Hacker News comment&lt;/a&gt; which helped me figure out the right way to do this.&lt;/p&gt;
&lt;p&gt;Software Heritage have all sorts of different IDs. The ID that worked for me was something they call a "snapshot ID". I found this in their "Permalinks" sidebar under the "snapshot" tag:&lt;/p&gt;
&lt;p&gt;&lt;a target="_blank" rel="noopener noreferrer" href="https://github.com/user-attachments/assets/8fa16a43-d52e-4fc7-8d94-0a291e7bd04b"&gt;&lt;img src="https://github.com/user-attachments/assets/8fa16a43-d52e-4fc7-8d94-0a291e7bd04b" alt="Screenshot of Software Heritage archive page for https://github.com/uktrade/sqlite-s3vfs showing visit type: git, dated 12 April 2025, 15:35:23 UTC. Navigation tabs include Code, Branches (38), Releases (0), and Visits. Branch: HEAD is selected with commit 8e0f4b6. A red &amp;quot;Permalinks&amp;quot; sidebar is expanded showing instructions: &amp;quot;To reference or cite the objects present in the Software Heritage archive, permalinks based on SoftWare Hash IDentifiers (SWHIDs) must be used. Select below a type of object currently browsed in order to display its associated SWHID and permalink.&amp;quot; Three options shown: directory, revision, and snapshot (selected). The SWHID displayed is &amp;quot;swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf;origin=https://github.com/uktrade/sqlite-s3vfs&amp;quot; with a checked &amp;quot;Add contextual information&amp;quot; checkbox and buttons for &amp;quot;Copy identifier&amp;quot; and &amp;quot;Copy permalink&amp;quot;." style="max-width: 100%;"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;It started &lt;code&gt;swh:1:snp:&lt;/code&gt; - the ID for this repository was:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You can then make an API call to request a Git bundle for that snapshot:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;curl -XPOST &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;https://archive.softwareheritage.org/api/1/vault/git-bare/swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf/&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;This returned JSON that looks like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-json"&gt;&lt;pre&gt;{
  &lt;span class="pl-ent"&gt;"fetch_url"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;https://archive.softwareheritage.org/api/1/vault/git-bare/swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf/raw/&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
  &lt;span class="pl-ent"&gt;"progress_message"&lt;/span&gt;: &lt;span class="pl-c1"&gt;null&lt;/span&gt;,
  &lt;span class="pl-ent"&gt;"id"&lt;/span&gt;: &lt;span class="pl-c1"&gt;417949633&lt;/span&gt;,
  &lt;span class="pl-ent"&gt;"status"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;done&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
  &lt;span class="pl-ent"&gt;"swhid"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
}&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;That &lt;code&gt;fetch_url&lt;/code&gt; is what you need to download the Git bundle as a &lt;code&gt;.tar.gz&lt;/code&gt; file. It redirects to blob storage so you need to use &lt;code&gt;-L&lt;/code&gt; with &lt;code&gt;curl&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;curl -L -o bundle.tar.gz &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;https://archive.softwareheritage.org/api/1/vault/git-bare/swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf/raw/&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Then decompress it:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;tar -xzvf bundle.tar.gz&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The result starts like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;x swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf.git/
x swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf.git/HEAD
x swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf.git/branches/
x swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf.git/config
x swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf.git/description
x swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf.git/hooks/
x swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf.git/info/
x swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf.git/info/exclude
x swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf.git/info/refs
x swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf.git/objects/
x swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf.git/objects/info/
x swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf.git/objects/info/packs
x swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf.git/objects/pack/
x swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf.git/objects/pack/pack-9946e5e52f40fd1df3352da074f9ac059e87ca9d.idx
x swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf.git/objects/pack/pack-9946e5e52f40fd1df3352da074f9ac059e87ca9d.pack
x swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf.git/refs/
x swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf.git/refs/heads/
x swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf.git/refs/heads/dependabot/
x swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf.git/refs/heads/dependabot/github_actions/
x swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf.git/refs/heads/dependabot/github_actions/dot-github/
x swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf.git/refs/heads/dependabot/github_actions/dot-github/workflows/
x swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf.git/refs/heads/dependabot/github_actions/dot-github/workflows/actions/
x swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf.git/refs/heads/dependabot/github_actions/dot-github/workflows/actions/download-artifact-4.1.7
x swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf.git/refs/heads/main
x swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf.git/refs/pull/
x swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf.git/refs/pull/11/
x swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf.git/refs/pull/11/head
...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That &lt;code&gt;swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf.git&lt;/code&gt; is a bare Git repository. You can clone it into a usable working copy like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;git clone swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf.git sqlite-s3vfs&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;I created an empty repo on GitHub and ran these commands to push everything up:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;&lt;span class="pl-c1"&gt;cd&lt;/span&gt; sqlite-s3vfs
git remote set-url origin git@github.com:simonw/sqlite-s3vfs.git
git push --all origin
git push --tags origin&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;I had to use &lt;code&gt;set-url&lt;/code&gt; because the original origin was that &lt;code&gt;/tmp/swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf.git&lt;/code&gt; local path.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;Building an HTML tool to make this easier in the future&lt;/h2&gt;&lt;a id="user-content-building-an-html-tool-to-make-this-easier-in-the-future" class="anchor" aria-label="Permalink: Building an HTML tool to make this easier in the future" href="#building-an-html-tool-to-make-this-easier-in-the-future"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;It turns out all of the relevant APIs are unauthenticated and support CORS headers, so I had Claude Code build me a page for automating part of this process in the future:&lt;/p&gt;
&lt;p&gt;&lt;a href="https://tools.simonwillison.net/software-heritage-repo" rel="nofollow"&gt;https://tools.simonwillison.net/software-heritage-repo&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Here's that tool with the old GitHub repo URL pre-filled, just click the button:&lt;/p&gt;
&lt;p&gt;&lt;a href="https://tools.simonwillison.net/software-heritage-repo#https%3A%2F%2Fgithub.com%2Fuktrade%2Fsqlite-s3vfs" rel="nofollow"&gt;https://tools.simonwillison.net/software-heritage-repo#https%3A%2F%2Fgithub.com%2Fuktrade%2Fsqlite-s3vfs&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;And here's &lt;a href="https://gistpreview.github.io/?3a76a868095c989d159c226b7622b092/index.html" rel="nofollow"&gt;the Claude Code transcript&lt;/a&gt; I used to build this.&lt;/p&gt;
</content>
    <link href="https://til.simonwillison.net/github/software-archive-recovery"/>
  </entry>
  <entry>
    <id>tag:til.simonwillison.net,2020-04-30:pytest_subtests.md</id>
    <title>Subtests in pytest 9.0.0+</title>
    <updated>2025-12-05T05:44:04+00:00</updated>
    <author>
      <name>Simon Willison</name>
      <uri>https://simonwillison.net/</uri>
    </author>
    <content type="html">&lt;p&gt;&lt;a href="https://pypi.org/project/pytest/9.0.0/" rel="nofollow"&gt;pytest 9.0.0&lt;/a&gt; was released on November 8th 2025. I just got around to looking at the &lt;a href="https://docs.pytest.org/en/stable/changelog.html#pytest-9-0-0-2025-11-05" rel="nofollow"&gt;release notes&lt;/a&gt; and the biggest new feature is &lt;strong&gt;subtests&lt;/strong&gt;, previously available as the separate &lt;a href="https://pypi.org/project/pytest-subtests/" rel="nofollow"&gt;pytest-subtests&lt;/a&gt; plugin.&lt;/p&gt;
&lt;p&gt;I copied the documentation into Claude Code and &lt;a href="https://gistpreview.github.io/?0487e5bb12bcbed850790a6324788e1b" rel="nofollow"&gt;told it to find a good place to try them&lt;/a&gt; in the Datasette test suite. It suggested &lt;a href="https://github.com/simonw/datasette/blob/1.0a23/tests/test_docs.py"&gt;tests/test_docs.py&lt;/a&gt;, which currently make heavy use of the &lt;code&gt;@pytest.mark.parametrize&lt;/code&gt; mechanism. I wrote about how that works a few years ago in &lt;a href="https://simonwillison.net/2018/Jul/28/documentation-unit-tests/" rel="nofollow"&gt;Documentation unit tests&lt;/a&gt; - here's an example test:&lt;/p&gt;
&lt;div class="highlight highlight-source-python"&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;parametrize&lt;/span&gt;(&lt;span class="pl-s"&gt;"setting"&lt;/span&gt;, &lt;span class="pl-s1"&gt;app&lt;/span&gt;.&lt;span class="pl-c1"&gt;SETTINGS&lt;/span&gt;)&lt;/span&gt;
&lt;span class="pl-k"&gt;def&lt;/span&gt; &lt;span class="pl-en"&gt;test_settings_are_documented&lt;/span&gt;(&lt;span class="pl-s1"&gt;settings_headings&lt;/span&gt;, &lt;span class="pl-s1"&gt;setting&lt;/span&gt;):
    &lt;span class="pl-k"&gt;assert&lt;/span&gt; &lt;span class="pl-s1"&gt;setting&lt;/span&gt;.&lt;span class="pl-c1"&gt;name&lt;/span&gt; &lt;span class="pl-c1"&gt;in&lt;/span&gt; &lt;span class="pl-s1"&gt;settings_headings&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;Understanding subtests&lt;/h2&gt;&lt;a id="user-content-understanding-subtests" class="anchor" aria-label="Permalink: Understanding subtests" href="#understanding-subtests"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;The idea behind subtests is to allow a test to programatically create new subtest within itself at runtime.&lt;/p&gt;
&lt;p&gt;My above example does the same thing using &lt;code&gt;@pytest.mark.parametrize&lt;/code&gt; - but it relies on the list of settings being known at test collection time. This might not be possible for things that need to be introspected after the test has run some initial setup code.&lt;/p&gt;
&lt;p&gt;Here's the above test ported to use subtests instead:&lt;/p&gt;
&lt;div class="highlight highlight-source-python"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;def&lt;/span&gt; &lt;span class="pl-en"&gt;test_settings_are_documented&lt;/span&gt;(&lt;span class="pl-s1"&gt;settings_headings&lt;/span&gt;, &lt;span class="pl-s1"&gt;subtests&lt;/span&gt;):
    &lt;span class="pl-k"&gt;for&lt;/span&gt; &lt;span class="pl-s1"&gt;setting&lt;/span&gt; &lt;span class="pl-c1"&gt;in&lt;/span&gt; &lt;span class="pl-s1"&gt;app&lt;/span&gt;.&lt;span class="pl-c1"&gt;SETTINGS&lt;/span&gt;:
        &lt;span class="pl-k"&gt;with&lt;/span&gt; &lt;span class="pl-s1"&gt;subtests&lt;/span&gt;.&lt;span class="pl-c1"&gt;test&lt;/span&gt;(&lt;span class="pl-s1"&gt;setting&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s1"&gt;setting&lt;/span&gt;.&lt;span class="pl-c1"&gt;name&lt;/span&gt;):
            &lt;span class="pl-k"&gt;assert&lt;/span&gt; &lt;span class="pl-s1"&gt;setting&lt;/span&gt;.&lt;span class="pl-c1"&gt;name&lt;/span&gt; &lt;span class="pl-c1"&gt;in&lt;/span&gt; &lt;span class="pl-s1"&gt;settings_headings&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;code&gt;subtests&lt;/code&gt; is a new default pytest fixture - if you list that as a parameter to your test function you can use it in the body of the test.&lt;/p&gt;
&lt;p&gt;Using &lt;code&gt;with subtests.test(...)&lt;/code&gt; creates a new subtest. Here I'm doing that in a loop. The keyword arguments passed to &lt;code&gt;subtests.test()&lt;/code&gt; are used to identify the subtest in the test report.&lt;/p&gt;
&lt;p&gt;That's all it takes! Here's &lt;a href="https://github.com/simonw/datasette/commit/1d4448fc5603f479f11b37b9da0ee11c2b1a19e4"&gt;a commit&lt;/a&gt; that ported several of my parameterized tests to use subtests instead.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;How subtests differ from parametrize&lt;/h2&gt;&lt;a id="user-content-how-subtests-differ-from-parametrize" class="anchor" aria-label="Permalink: How subtests differ from parametrize" href="#how-subtests-differ-from-parametrize"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;If you use &lt;code&gt;@pytest.mark.parametrize&lt;/code&gt; pytest will behave as if every one of your parameter combinations is a separate test function. Running the old &lt;code&gt;pytest tests/test_docs.py&lt;/code&gt; tests looked like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.0.1, pluggy-1.6.0
SQLite: 3.50.4
rootdir: /private/tmp/datasette
configfile: pytest.ini
plugins: anyio-4.12.0, xdist-3.8.0, timeout-2.4.0, asyncio-1.3.0
asyncio: mode=Mode.STRICT, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
collected 120 items                                                            

tests/test_docs.py ..................................................... [ 44%]
...................................................................      [100%]

============================= 120 passed in 0.84s ==============================
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Porting to subtests causes each test to be reported just once:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;...
collected 9 items                                                             

tests/test_docs.py .........                                            [100%]

============================== 9 passed in 0.15s ==============================
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;But... if you add &lt;code&gt;-v&lt;/code&gt; for verbose output you get back a report that &lt;em&gt;does&lt;/em&gt; include every subtest. Truncated, that looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;...
collected 9 items                                                                                                

tests/test_docs.py::test_settings_are_documented SUBPASSED(setting='default_page_size')                    [ 11%]
tests/test_docs.py::test_settings_are_documented SUBPASSED(setting='max_returned_rows')                    [ 11%]
...
tests/test_docs.py::test_settings_are_documented PASSED                                                    [ 11%]
tests/test_docs.py::test_plugin_hooks_are_documented SUBPASSED(plugin='actor_from_request')                [ 22%]
tests/test_docs.py::test_plugin_hooks_are_documented SUBPASSED(plugin='actors_from_ids')                   [ 22%]
tests/test_docs.py::test_plugin_hooks_are_documented PASSED                                                [ 22%]...

...
tests/test_docs.py::test_rst_heading_underlines_match_title_length PASSED                                  [ 66%]
tests/test_docs.py::test_homepage PASSED                                                                   [ 77%]
tests/test_docs.py::test_actor_is_null PASSED                                                              [ 88%]
tests/test_docs.py::test_signed_cookie_actor PASSED                                                        [100%]

============================== 9 passed, 116 subtests passed in 0.15s ==============================
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The last line shows how many subtests passed in addition to how many tests.&lt;/p&gt;
&lt;p&gt;It looks to me like subtests run substantially faster than the eqpuivalent parameterized tests. I'm more interested in the fact that subtests can now be programatically generated at runtime based on test setup code.&lt;/p&gt;
</content>
    <link href="https://til.simonwillison.net/pytest/subtests"/>
  </entry>
  <entry>
    <id>tag:til.simonwillison.net,2020-04-30:uv_dependency-groups.md</id>
    <title>Dependency groups and uv run</title>
    <updated>2025-12-03T04:55:17+00:00</updated>
    <author>
      <name>Simon Willison</name>
      <uri>https://simonwillison.net/</uri>
    </author>
    <content type="html">&lt;p&gt;I've adopted a new (to me) pattern for my Python projects to make them easier to hack on using &lt;code&gt;uv run&lt;/code&gt;. I'm using a &lt;a href="https://peps.python.org/pep-0735/" rel="nofollow"&gt;PEP 735 dependency group&lt;/a&gt; called &lt;code&gt;dev&lt;/code&gt; to declare my development dependencies - in particular &lt;code&gt;pytest&lt;/code&gt; - such that running &lt;code&gt;uv run pytest&lt;/code&gt; executes the tests for my project without me having to even think about setting up a virtual environment first.&lt;/p&gt;
&lt;p&gt;Here's the pattern I'm using. I start by creating a new library using &lt;code&gt;uv init --lib&lt;/code&gt; like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;mkdir my-new-library
&lt;span class="pl-c1"&gt;cd&lt;/span&gt; my-new-library
uv init --lib&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;This creates a &lt;code&gt;pyproject.toml&lt;/code&gt; file like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-toml"&gt;&lt;pre&gt;[&lt;span class="pl-en"&gt;project&lt;/span&gt;]
&lt;span class="pl-smi"&gt;name&lt;/span&gt; = &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;my-new-library&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
&lt;span class="pl-smi"&gt;version&lt;/span&gt; = &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;0.1.0&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
&lt;span class="pl-smi"&gt;description&lt;/span&gt; = &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Add your description here&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
&lt;span class="pl-smi"&gt;readme&lt;/span&gt; = &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;README.md&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
&lt;span class="pl-smi"&gt;authors&lt;/span&gt; = [
    { &lt;span class="pl-smi"&gt;name&lt;/span&gt; = &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Simon Willison&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;, &lt;span class="pl-smi"&gt;email&lt;/span&gt; = &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;...@gmail.com&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; }
]
&lt;span class="pl-smi"&gt;requires-python&lt;/span&gt; = &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&amp;gt;=3.10&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
&lt;span class="pl-smi"&gt;dependencies&lt;/span&gt; = []

[&lt;span class="pl-en"&gt;build-system&lt;/span&gt;]
&lt;span class="pl-smi"&gt;requires&lt;/span&gt; = [&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;uv_build&amp;gt;=0.9.15,&amp;lt;0.10.0&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;]
&lt;span class="pl-smi"&gt;build-backend&lt;/span&gt; = &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;uv_build&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;It also creates a &lt;code&gt;src/my_new_library/__init__.py&lt;/code&gt; file with a &lt;code&gt;hello()&lt;/code&gt; example function.&lt;/p&gt;
&lt;p&gt;Next, I add &lt;code&gt;pytest&lt;/code&gt; as a development dependency using this command:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;uv add --dev pytest&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Doing so adds a new section to the end of that &lt;code&gt;pyproject.toml&lt;/code&gt; file:&lt;/p&gt;
&lt;div class="highlight highlight-source-toml"&gt;&lt;pre&gt;[&lt;span class="pl-en"&gt;dependency-groups&lt;/span&gt;]
&lt;span class="pl-smi"&gt;dev&lt;/span&gt; = [
    &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;pytest&amp;gt;=9.0.1&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
]&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;This also creates the virtual environment in &lt;code&gt;.venv&lt;/code&gt; and &lt;code&gt;uv.lock&lt;/code&gt; file, but we don't need to think about those.&lt;/p&gt;
&lt;p&gt;Then I create a test:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;mkdir tests
&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;from my_new_library import hello&lt;/span&gt;
&lt;span class="pl-s"&gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;def test_hello():&lt;/span&gt;
&lt;span class="pl-s"&gt;    assert hello() == "Hello from my-new-library!"&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;&amp;gt;&lt;/span&gt; tests/test_my_new_library.py&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Now I can run that test suite using:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;uv run pytest&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The &lt;code&gt;dev&lt;/code&gt; dependency group is a special case for &lt;code&gt;uv run&lt;/code&gt; - it will always install those dependencies as well such that commands like &lt;code&gt;pytest&lt;/code&gt; can work correctly.&lt;/p&gt;
&lt;p&gt;When you package a project for distribution the &lt;code&gt;dev&lt;/code&gt; dependencies will not be automatically installed for users of your package.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;The importance of [build-system] for specifying a package&lt;/h2&gt;&lt;a id="user-content-the-importance-of-build-system-for-specifying-a-package" class="anchor" aria-label="Permalink: The importance of [build-system] for specifying a package" href="#the-importance-of-build-system-for-specifying-a-package"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;That &lt;code&gt;[build-system]&lt;/code&gt; section is crucial, because it tells &lt;code&gt;uv&lt;/code&gt; that the directory should be treated as a "package". This means that when &lt;code&gt;uv run&lt;/code&gt; executes it installs the current directory as an editable package in the virtual environment.&lt;/p&gt;
&lt;p&gt;Removing &lt;code&gt;[build-system]&lt;/code&gt; and then &lt;code&gt;rm -rf .venv&lt;/code&gt; to delete the virtual environment results in the following error when trying to run &lt;code&gt;uv run pytest&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;________________ ERROR collecting tests/test_my_new_library.py _________________
ImportError while importing test module '/private/tmp/my-new-library/tests/test_my_new_library.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
/opt/homebrew/Caskroom/miniconda/base/lib/python3.10/importlib/__init__.py:126: in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
tests/test_my_new_library.py:1: in &amp;lt;module&amp;gt;
    from my_new_library import hello
E   ModuleNotFoundError: No module named 'my_new_library'
=========================== short test summary info ============================
ERROR tests/test_my_new_library.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
=============================== 1 error in 0.07s ===============================
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Adding the &lt;code&gt;[build-system]&lt;/code&gt; section back in gets rid of this error.&lt;/p&gt;
&lt;p&gt;There's an alternative to having a &lt;code&gt;[build-system]&lt;/code&gt; section, which is to add this to the &lt;code&gt;pyproject.toml&lt;/code&gt; file instead:&lt;/p&gt;
&lt;div class="highlight highlight-source-toml"&gt;&lt;pre&gt;[&lt;span class="pl-en"&gt;tool&lt;/span&gt;.&lt;span class="pl-en"&gt;uv&lt;/span&gt;]
&lt;span class="pl-smi"&gt;package&lt;/span&gt; = &lt;span class="pl-c1"&gt;true&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;I &lt;a href="https://gistpreview.github.io/?e5405e5770d963ee708eea5ecc769457" rel="nofollow"&gt;had Claude Code investigate this&lt;/a&gt; and it found &lt;a href="https://github.com/astral-sh/uv/blob/d2db06983a20630de4247e4a94af7edd5aa35689/crates/uv-workspace/src/pyproject.rs#L135-L143"&gt;this section of code&lt;/a&gt; explaining what's going on:&lt;/p&gt;
&lt;div class="highlight highlight-source-rust"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;pub&lt;/span&gt; &lt;span class="pl-k"&gt;fn&lt;/span&gt; &lt;span class="pl-en"&gt;is_package&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-c1"&gt;&amp;amp;&lt;/span&gt;&lt;span class="pl-smi"&gt;self&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-s1"&gt;require_build_system&lt;/span&gt;&lt;span class="pl-kos"&gt;:&lt;/span&gt; &lt;span class="pl-smi"&gt;bool&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; -&amp;gt; &lt;span class="pl-smi"&gt;bool&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
    &lt;span class="pl-c"&gt;// If `tool.uv.package` is set, defer to that explicit setting.&lt;/span&gt;
    &lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-k"&gt;let&lt;/span&gt; &lt;span class="pl-v"&gt;Some&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;is_package&lt;span class="pl-kos"&gt;)&lt;/span&gt; = &lt;span class="pl-smi"&gt;self&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;tool_uv_package&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; is_package&lt;span class="pl-kos"&gt;;&lt;/span&gt;
    &lt;span class="pl-kos"&gt;}&lt;/span&gt;
    &lt;span class="pl-c"&gt;// Otherwise, a project is assumed to be a package if `build-system` is present.&lt;/span&gt;
    &lt;span class="pl-smi"&gt;self&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;build_system&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;is_some&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; || !require_build_system
&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;So there's no need to use that &lt;code&gt;[tool.uv]&lt;/code&gt; section if you already have a &lt;code&gt;[build-system]&lt;/code&gt; section.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;Installation in CI&lt;/h2&gt;&lt;a id="user-content-installation-in-ci" class="anchor" aria-label="Permalink: Installation in CI" href="#installation-in-ci"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;If you're still using regular &lt;code&gt;pip&lt;/code&gt; in your CI scripts you'll need to ensure the &lt;code&gt;dev&lt;/code&gt; dependency group is installed like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;pip install &lt;span class="pl-c1"&gt;.&lt;/span&gt; --group dev&lt;/pre&gt;&lt;/div&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;The end result&lt;/h2&gt;&lt;a id="user-content-the-end-result" class="anchor" aria-label="Permalink: The end result" href="#the-end-result"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;Projects that use this pattern become a whole lot easier for other people to hack on. I first used this for my &lt;a href="https://github.com/datasette/datasette-extract"&gt;datasette-extract&lt;/a&gt; package, with the result that checking out and running the tests is now a case of running just the following commands:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;git clone https://github.com/datasette/datasette-extract
&lt;span class="pl-c1"&gt;cd&lt;/span&gt; datasette-extract
uv run pytest&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;No need to think about virtual environments or development dependencies - this just works.&lt;/p&gt;
&lt;p&gt;Building a distributable wheel of the project is a one-liner as well:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;uv build&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;This creates two files:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;dist/datasette_extract-0.2a0-py3-none-any.whl
dist/datasette_extract-0.2a0.tar.gz
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;.tar.gz&lt;/code&gt; file should contain everything including the tests - the &lt;code&gt;.whl&lt;/code&gt; file should contain just the non-development Python code.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;Bonus tip: defining dev in terms of other dependency groups&lt;/h2&gt;&lt;a id="user-content-bonus-tip-defining-dev-in-terms-of-other-dependency-groups" class="anchor" aria-label="Permalink: Bonus tip: defining dev in terms of other dependency groups" href="#bonus-tip-defining-dev-in-terms-of-other-dependency-groups"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;The &lt;code&gt;dev&lt;/code&gt; group is a special case: tools like &lt;code&gt;uv run&lt;/code&gt; will install everything in that group automatically.&lt;/p&gt;
&lt;p&gt;What if you want to divide up your dependencies into test dependencies, documentation dependencies and so on?&lt;/p&gt;
&lt;p&gt;Thanks to &lt;a href="https://github.com/mgaitan/python-package-copier-template/blob/676a89117b10f6267cfe45dd1c36efc80af95be7/project/pyproject.toml.jinja#L97-L121"&gt;Martín Gaitán's python-package-copier-template&lt;/a&gt; I learned about this pattern for defining &lt;code&gt;dev&lt;/code&gt; in terms of other groups:&lt;/p&gt;
&lt;div class="highlight highlight-source-toml"&gt;&lt;pre&gt;[&lt;span class="pl-en"&gt;dependency-groups&lt;/span&gt;]
&lt;span class="pl-smi"&gt;test&lt;/span&gt; = [
    &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;pytest&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
    &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;pytest-asyncio&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
]
&lt;span class="pl-smi"&gt;docs&lt;/span&gt; = [
    &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;sphinx&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
]
&lt;span class="pl-smi"&gt;dev&lt;/span&gt; = [
    { &lt;span class="pl-smi"&gt;include-group&lt;/span&gt; = &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;test&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; },
    { &lt;span class="pl-smi"&gt;include-group&lt;/span&gt; = &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;docs&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; }
]&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Now if you just want to use the test dependencies you can run this:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;uv run --group &lt;span class="pl-c1"&gt;test&lt;/span&gt; pytest&lt;/pre&gt;&lt;/div&gt;
</content>
    <link href="https://til.simonwillison.net/uv/dependency-groups"/>
  </entry>
  <entry>
    <id>tag:til.simonwillison.net,2020-04-30:llms_codex-spark-gpt-oss.md</id>
    <title>Using Codex CLI with gpt-oss:120b on an NVIDIA DGX Spark via Tailscale</title>
    <updated>2025-11-07T07:16:55+00:00</updated>
    <author>
      <name>Simon Willison</name>
      <uri>https://simonwillison.net/</uri>
    </author>
    <content type="html">&lt;p&gt;I've written about the &lt;a href="https://simonwillison.net/2025/Oct/14/nvidia-dgx-spark/" rel="nofollow"&gt;DGX Spark&lt;/a&gt; before. Here's how I got OpenAI's Codex CLI to run on my Mac against a gpt-oss:120b model running on the DGX Spark via a Tailscale network.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;Setting up Tailscale&lt;/h2&gt;&lt;a id="user-content-setting-up-tailscale" class="anchor" aria-label="Permalink: Setting up Tailscale" href="#setting-up-tailscale"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;I installed Tailscale on the Spark a while ago and didn't make detailed notes. The Spark runs Ubuntu so I followed the &lt;a href="https://tailscale.com/kb/1031/install-linux" rel="nofollow"&gt;Tailscale Linux installation instructions&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;My Mac is signed into Tailscale as well. I obtained the IP address for the Spark from the MacOS system tray menu: Network Devices -&amp;gt; My Devices -&amp;gt; spark-18b3 (the hostname).&lt;/p&gt;
&lt;p&gt;For me that was:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;100.113.1.114
&lt;/code&gt;&lt;/pre&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;Ollama on the Spark&lt;/h2&gt;&lt;a id="user-content-ollama-on-the-spark" class="anchor" aria-label="Permalink: Ollama on the Spark" href="#ollama-on-the-spark"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;I installed &lt;a href="https://ollama.com/" rel="nofollow"&gt;Ollama&lt;/a&gt; on the Spark using their &lt;a href="https://ollama.com/download/linux" rel="nofollow"&gt;installation script&lt;/a&gt;. This got it running as a service, but the default settings bind it to localhost only. I wanted to access it from other machines, so I did the following (with the &lt;a href="https://gistpreview.github.io/?8185e078044004e2d4c3f86fc979fd23" rel="nofollow"&gt;help of Claude Code&lt;/a&gt;):&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;sudo mkdir -p /etc/systemd/system/ollama.service.d
sudo bash -c &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;cat &amp;gt; /etc/systemd/system/ollama.service.d/override.conf &amp;lt;&amp;lt;EOF&lt;/span&gt;
&lt;span class="pl-s"&gt;[Service]&lt;/span&gt;
&lt;span class="pl-s"&gt;Environment="OLLAMA_HOST=0.0.0.0:11434"&lt;/span&gt;
&lt;span class="pl-s"&gt;EOF&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Then restarted Ollama:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;sudo systemctl daemon-reload
sudo systemctl restart ollama&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;I confirmed it was running by visiting this URL on my Mac:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;http://100.113.1.114:11434/&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;Which returned the text:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Ollama is running
&lt;/code&gt;&lt;/pre&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;Hitting Ollama from the Mac&lt;/h2&gt;&lt;a id="user-content-hitting-ollama-from-the-mac" class="anchor" aria-label="Permalink: Hitting Ollama from the Mac" href="#hitting-ollama-from-the-mac"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;I had Ollama installed on my Mac already as well, which meant the &lt;code&gt;ollama&lt;/code&gt; client tool was available.&lt;/p&gt;
&lt;p&gt;Setting the &lt;code&gt;OLLAMA_HOST&lt;/code&gt; environment variable to point to the Spark means that client tool can talk to that Ollama server instead.&lt;/p&gt;
&lt;p&gt;Here's an example session on my Mac. &lt;code&gt;ollama ls&lt;/code&gt; on its own shows the models installed locally on my Mac:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;~ % ollama ls
NAME                ID              SIZE      MODIFIED      
gpt-oss:20b         aa4295ac10c3    13 GB     7 weeks ago      
gemma3:270m         b16d6d39dfbd    241 MB    2 months ago     
gemma3:4b           c0494fe00251    3.3 GB    7 months ago     
deepseek-r1:1.5b    a42b25d8c10a    1.1 GB    9 months ago     
llama3.2:3b         a80c4f17acd5    2.0 GB    10 months ago    
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;But if I add &lt;code&gt;OLLAMA_HOST=&lt;/code&gt; before the command I talk to the Spark instead:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;~ % OLLAMA_HOST=100.113.1.114:11434 ollama ls
NAME            ID              SIZE      MODIFIED    
llama3.2:3b     a80c4f17acd5    2.0 GB    6 weeks ago    
gpt-oss:120b    f7f8e2f8f4e0    65 GB     7 weeks ago    
gpt-oss:20b     aa4295ac10c3    13 GB     7 weeks ago    
qwen3:4b        e55aed6fe643    2.5 GB    7 weeks ago    
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It already had the 120b model, but if it didn't I could pull it with this command:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;OLLAMA_HOST=100.113.1.114:11434 ollama pull gpt-oss:120b
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I can start an interactive chat session with the model like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;~ % OLLAMA_HOST=100.113.1.114:11434 ollama run gpt-oss:120b  
&amp;gt;&amp;gt;&amp;gt; hi
Thinking...
The user says "hi". Need to respond friendly. Probably ask how can help.
...done thinking.

Hello! 👋 How can I assist you today?
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Or use my &lt;a href="https://llm.datasette.io/" rel="nofollow"&gt;LLM&lt;/a&gt; tool with the llm-ollama plugin like so:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;uv tool install llm
llm install llm-ollama
OLLAMA_HOST=100.113.1.114:11434 llm -m gpt-oss:120b \
   &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Write a haiku about Tailscale&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Cute:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Clouds become a thread  
Phones and laptops link as one  
Network breathes as one
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Setting &lt;code&gt;export OLLAMA_HOST=100.113.1.114:11434&lt;/code&gt; saves me from having to type it each time.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;Running the Codex CLI&lt;/h2&gt;&lt;a id="user-content-running-the-codex-cli" class="anchor" aria-label="Permalink: Running the Codex CLI" href="#running-the-codex-cli"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;This was harder to figure out - I'm not sure the Ollama mechanis is particularly stable, so I ended up checking out &lt;a href="https://github.com/openai/codex"&gt;openai/codex&lt;/a&gt; and having Codex with GPT-5 figure out how to configure that OLLAMA_HOST setting. Here's &lt;a href="https://gistpreview.github.io/?54dcf8b4b77ddfb87111af9c759b54e6" rel="nofollow"&gt;that transcript&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The recipe that worked for me was this one:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;CODEX_OSS_BASE_URL=http://100.113.1.114:11434/v1 \
  codex --oss --model gpt-oss:120b&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The &lt;code&gt;CODEX_OSS_BASE_URL&lt;/code&gt; environment variable tells Codex CLI where to find the Ollama server API endpoint. By default it uses the smaller gpt-oss:20b model, but passing &lt;code&gt;--model gpt-oss:120b&lt;/code&gt; tells it to use the 120b model instead.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;Building Space Invaders&lt;/h2&gt;&lt;a id="user-content-building-space-invaders" class="anchor" aria-label="Permalink: Building Space Invaders" href="#building-space-invaders"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;I ran this sequence of prompts inside Codex:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;make /tmp/invaders and cd to that&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;build space invaders as a single HTML file&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;init a git repo and commit your code&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Make the space invaders different colors&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Here's &lt;a href="https://static.simonwillison.net/static/2025/gpt-oss-120b-invaders.html" rel="nofollow"&gt;the resulting game&lt;/a&gt;, and a copy of &lt;a href="https://gistpreview.github.io/?ce40fafd9c3258e74246fdd2489c461b" rel="nofollow"&gt;the transcript&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;So it works! I can now run Codex on my Mac against a competent-enough model on the Spark, via Tailscale which means I can access it using my laptop anywhere in the world.&lt;/p&gt;
&lt;p&gt;I don't think I'll use this heavily - GPT-5 and Sonnet 4.5 remain wildly more capable than gpt-oss:120b - but it's neat to be able to do this.&lt;/p&gt;
</content>
    <link href="https://til.simonwillison.net/llms/codex-spark-gpt-oss"/>
  </entry>
  <entry>
    <id>tag:til.simonwillison.net,2020-04-30:llms_o4-mini-deep-research.md</id>
    <title>Exploring OpenAI's deep research API model o4-mini-deep-research</title>
    <updated>2025-10-18T18:46:03+00:00</updated>
    <author>
      <name>Simon Willison</name>
      <uri>https://simonwillison.net/</uri>
    </author>
    <content type="html">&lt;p&gt;I was reviewing some older PRs and landed &lt;a href="https://github.com/simonw/llm-prices/pull/9"&gt;this one&lt;/a&gt; by Manuel Solorzano adding pricing for &lt;a href="https://platform.openai.com/docs/models/o4-mini-deep-research" rel="nofollow"&gt;o4-mini-deep-research&lt;/a&gt; and &lt;a href="https://platform.openai.com/docs/models/o3-deep-research" rel="nofollow"&gt;o3-deep-research&lt;/a&gt; to my &lt;a href="https://www.llm-prices.com/" rel="nofollow"&gt;llm-prices.com&lt;/a&gt; site. I realized I hadn't tried those models yet so I decided to give one of them a go.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;o4-mini-deep-research&lt;/code&gt; is significantly cheaper than &lt;code&gt;o3-deep-research&lt;/code&gt; - $2/$8 per million for input/output compared to $10/$40 - so I tried that one.&lt;/p&gt;
&lt;p&gt;These models are only available via the Responses API and recommend running using their "background" mode. My &lt;a href="https://llm.datasette.io/" rel="nofollow"&gt;LLM&lt;/a&gt; tooling doesn't support that yet so I used &lt;code&gt;curl&lt;/code&gt; instead.&lt;/p&gt;
&lt;p&gt;Here's what I tried:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;curl https://api.openai.com/v1/responses \
  -H &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Authorization: Bearer &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;$(&lt;/span&gt;llm keys get openai&lt;span class="pl-pds"&gt;)&lt;/span&gt;&lt;/span&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; \
  -H &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Content-Type: application/json&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; \
  -d &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;{&lt;/span&gt;
&lt;span class="pl-s"&gt;    "model": "o4-mini-deep-research-2025-06-26",&lt;/span&gt;
&lt;span class="pl-s"&gt;    "input": "Find locations of every surviving orchestrion in the world. Include the city, country, venue, latitude, longitude, brief description and if available a history of when and where that orchestrion was made and its path to its current location. Return a JSON array of objects. Include a notes field for anything interesting that doesn&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;&lt;span class="pl-cce"&gt;\'&lt;/span&gt;&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;t fit the other fields.",&lt;/span&gt;
&lt;span class="pl-s"&gt;    "reasoning": { "summary": "auto" },&lt;/span&gt;
&lt;span class="pl-s"&gt;    "background": true,&lt;/span&gt;
&lt;span class="pl-s"&gt;    "tools": [&lt;/span&gt;
&lt;span class="pl-s"&gt;      { "type": "web_search_preview" },&lt;/span&gt;
&lt;span class="pl-s"&gt;      { "type": "code_interpreter", "container": { "type": "auto" } }&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;/pre&gt;&lt;/div&gt;
&lt;p&gt;Here's the prompt I'm using (I &lt;a href="https://www.niche-museums.com/115" rel="nofollow"&gt;really like orchestrions&lt;/a&gt;):&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Find locations of every surviving orchestrion in the world. Include the city, country, venue, latitude, longitude, brief description and if available a history of when and where that orchestrion was made and its path to its current location. Return a JSON array of objects. Include a notes field for anything interesting that doesn't fit the other fields.&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;This returned a response ID and `"status": "queued", which started like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-json"&gt;&lt;pre&gt;{
  &lt;span class="pl-ent"&gt;"id"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;resp_04911070f2b5d8560068f3ccd776788190b51f5497d7b27a30&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
  &lt;span class="pl-ent"&gt;"object"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;response&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
  &lt;span class="pl-ent"&gt;"created_at"&lt;/span&gt;: &lt;span class="pl-c1"&gt;1760808151&lt;/span&gt;,
  &lt;span class="pl-ent"&gt;"status"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;queued&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;I then polled that ID for the result, looking at the &lt;code&gt;"status"&lt;/code&gt; field with &lt;code&gt;jq&lt;/code&gt; each time:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;curl https://api.openai.com/v1/responses/resp_04911070f2b5d8560068f3ccd776788190b51f5497d7b27a30 \
  -H &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Authorization: Bearer &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;$(&lt;/span&gt;llm keys get openai&lt;span class="pl-pds"&gt;)&lt;/span&gt;&lt;/span&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; \
  -H &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Content-Type: application/json&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;|&lt;/span&gt; jq .status&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;That returned &lt;code&gt;"in_progress"&lt;/code&gt; for a while, and then finally &lt;code&gt;"succeeded"&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Here's &lt;a href="https://gist.github.com/simonw/3454a4ce40f8547a5c65c911de611ff4"&gt;the full JSON&lt;/a&gt; that came back from that final request. That's 90KB of data (after pretty-printing with &lt;code&gt;jq&lt;/code&gt;).&lt;/p&gt;
&lt;p&gt;First lets look at the cost. That JSON ended with this &lt;code&gt;"usage"&lt;/code&gt; block:&lt;/p&gt;
&lt;div class="highlight highlight-source-json"&gt;&lt;pre&gt;{
    &lt;span class="pl-ent"&gt;"input_tokens"&lt;/span&gt;: &lt;span class="pl-c1"&gt;60506&lt;/span&gt;,
    &lt;span class="pl-ent"&gt;"input_tokens_details"&lt;/span&gt;: {
        &lt;span class="pl-ent"&gt;"cached_tokens"&lt;/span&gt;: &lt;span class="pl-c1"&gt;0&lt;/span&gt;
    },
    &lt;span class="pl-ent"&gt;"output_tokens"&lt;/span&gt;: &lt;span class="pl-c1"&gt;22883&lt;/span&gt;,
    &lt;span class="pl-ent"&gt;"output_tokens_details"&lt;/span&gt;: {
        &lt;span class="pl-ent"&gt;"reasoning_tokens"&lt;/span&gt;: &lt;span class="pl-c1"&gt;20416&lt;/span&gt;
    },
    &lt;span class="pl-ent"&gt;"total_tokens"&lt;/span&gt;: &lt;span class="pl-c1"&gt;83389&lt;/span&gt;
}&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Running this &lt;a href="https://www.llm-prices.com/#it=60506&amp;amp;ot=22883&amp;amp;ic=2&amp;amp;cic=0.5&amp;amp;oc=8&amp;amp;sel=o4-mini-deep-research" rel="nofollow"&gt;through llm-prices.com&lt;/a&gt; for $2/$8 per million input/output tokens gives a total of 30.4076 cents.&lt;/p&gt;
&lt;p&gt;But what about the searches it ran? Annoyingly, OpenAI don't include those in the &lt;code&gt;"usage"&lt;/code&gt; block. As far as I can tell you have to calculate them yourself by counting the number of &lt;code&gt;"web_search_preview"&lt;/code&gt; tool calls it made.&lt;/p&gt;
&lt;p&gt;I count 77 of those. OpenAI's &lt;a href="https://openai.com/api/pricing/" rel="nofollow"&gt;pricing page&lt;/a&gt; says $10.00 / 1K calls, so 1 cent per call, so that's 77 cents on search.&lt;/p&gt;
&lt;p&gt;It also fired up code interpreter for a few things (mostly co-ordinate trasforms with &lt;code&gt;pyproj&lt;/code&gt;) - the pricing page says "$0.03 / session" so I guess that's another 3 cents.&lt;/p&gt;
&lt;p&gt;Total cost: 77 + 30.4076 + 3 = &lt;strong&gt;110.4076 cents&lt;/strong&gt;, $1.10.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;&lt;h4 class="heading-element"&gt;The results&lt;/h4&gt;&lt;a id="user-content-the-results" class="anchor" aria-label="Permalink: The results" href="#the-results"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;I &lt;a href="https://github.com/simonw/tools/pull/69"&gt;added a feature&lt;/a&gt; (with &lt;a href="https://docs.github.com/en/enterprise-cloud@latest/copilot/concepts/agents/coding-agent/about-coding-agent"&gt;GitHub Copilot coding agent&lt;/a&gt; to my &lt;a href="https://tools.simonwillison.net/json-string-extractor" rel="nofollow"&gt;JSON string extractor&lt;/a&gt; tool to help display the result. Here's &lt;a href="https://tools.simonwillison.net/json-string-extractor#gist=3454a4ce40f8547a5c65c911de611ff4" rel="nofollow"&gt;the long strings extracted&lt;/a&gt; from that full JSON.&lt;/p&gt;
&lt;p&gt;The thought process started like this:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Searching for orchestrions&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;I’m looking at a static page about the historical context of orchestrions, but it seems like it doesn't list where surviving ones are located. This museum might be in Switzerland and could include images. To find currently existing orchestrions, I need to explore mechanical music museums like Speelklok or check old hotels that might still have them. An orchestrion is a large self-playing instrument, often with pipes or violins. Let's search for mechanical music museums featuring orchestrions!&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I &lt;a href="https://gist.github.com/simonw/d225134d348435c8106070cb74c78888"&gt;vibe-coded up&lt;/a&gt; a quick viewer for this using Claude Code, here's &lt;a href="https://tools.simonwillison.net/deep-research-viewer#gist=3454a4ce40f8547a5c65c911de611ff4" rel="nofollow"&gt;that full trace in a UI&lt;/a&gt;. That one claims 45 searches rather than 77, I asked Claude Code and it noted that some of those &lt;code&gt;"type": "web_search_preview"&lt;/code&gt; calls were &lt;code&gt;"action": "search"&lt;/code&gt; but others were &lt;code&gt;"action": "open_page"&lt;/code&gt;. I don't know if &lt;code&gt;"open_page"&lt;/code&gt; calls count the same as searches for pricing, but the tool shows 45 searches and 24 pages visited (and 12 code executions).&lt;/p&gt;
&lt;p&gt;&lt;a target="_blank" rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/cecb7cc2adea06a1912c0339898bdd779b283c50404d70fc4bfbf4c80b1a8c45/68747470733a2f2f7374617469632e73696d6f6e77696c6c69736f6e2e6e65742f7374617469632f323032352f646565702d72657365617263682d7669657765722e6a7067"&gt;&lt;img src="https://camo.githubusercontent.com/cecb7cc2adea06a1912c0339898bdd779b283c50404d70fc4bfbf4c80b1a8c45/68747470733a2f2f7374617469632e73696d6f6e77696c6c69736f6e2e6e65742f7374617469632f323032352f646565702d72657365617263682d7669657765722e6a7067" alt="UI showing 17 thinking steps, 45 searches, 24 pages visited, 12 code executions and 180 total steps - then a thinknig block with the researching orchestrions output I quoted earlier, then a search step for &amp;quot;surving orchestrions&amp;quot; locations" data-canonical-src="https://static.simonwillison.net/static/2025/deep-research-viewer.jpg" style="max-width: 100%;"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;The output also included the JSON I had asked for - a &lt;a href="https://gist.github.com/simonw/2a0b26633802149a44e15cf1cd396f86"&gt;JSON array of 19 orchestrions&lt;/a&gt;. I dropped that in a Gist and &lt;a href="https://lite.datasette.io/?json=https://gist.github.com/simonw/2a0b26633802149a44e15cf1cd396f86#/data/raw" rel="nofollow"&gt;opened it in Datasette Lite&lt;/a&gt; to explore it.&lt;/p&gt;
&lt;p&gt;Then I switched back to Claude Code and had it &lt;a href="https://gist.github.com/simonw/ee3854ffc5d5da06591c1106d6594aa5#file-make-orchestrion-txt-L14"&gt;convert that&lt;/a&gt; to GeoJSON, so I could &lt;a href="https://gist.github.com/simonw/cfcee19020ad11f8fbf56267b08e466a"&gt;view it GitHub's GeoJSON viewer&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Finally I had Claude Code &lt;a href="https://gist.github.com/simonw/ee3854ffc5d5da06591c1106d6594aa5#file-make-orchestrion-txt-L70"&gt;knock out&lt;/a&gt; a custom HTML viewer just for that file, with de-duplication across identical venues. Here's &lt;a href="https://gist.github.com/simonw/b9f5416b37c4ceec46d8447b52be0ad2"&gt;the resulting HTML&lt;/a&gt;, which you can see rendered via &lt;code&gt;gitpreview.github.io&lt;/code&gt; here:&lt;/p&gt;
&lt;p&gt;&lt;a href="https://gistpreview.github.io/?b9f5416b37c4ceec46d8447b52be0ad2" rel="nofollow"&gt;https://gistpreview.github.io/?b9f5416b37c4ceec46d8447b52be0ad2&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a target="_blank" rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/20d6e93c8e7396e5134ee5f40d4afda0cc65c36e546c0ed6267e519298ae9f12/68747470733a2f2f7374617469632e73696d6f6e77696c6c69736f6e2e6e65742f7374617469632f323032352f6f72636865737472696f6e732d61726f756e642d7468652d776f726c642e6a7067"&gt;&lt;img src="https://camo.githubusercontent.com/20d6e93c8e7396e5134ee5f40d4afda0cc65c36e546c0ed6267e519298ae9f12/68747470733a2f2f7374617469632e73696d6f6e77696c6c69736f6e2e6e65742f7374617469632f323032352f6f72636865737472696f6e732d61726f756e642d7468652d776f726c642e6a7067" alt="Screenshot of the site. It lists details of an orchestrion at the Brentford Musical Museum in London." data-canonical-src="https://static.simonwillison.net/static/2025/orchestrions-around-the-world.jpg" style="max-width: 100%;"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;I'm using a secret URL Gist served through &lt;code&gt;gistpreview.github.io&lt;/code&gt; here to reduce the chance of this unverified slop content getting indexed by crawlers and making it out into the wider world.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;&lt;h4 class="heading-element"&gt;How well did it do?&lt;/h4&gt;&lt;a id="user-content-how-well-did-it-do" class="anchor" aria-label="Permalink: How well did it do?" href="#how-well-did-it-do"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;It found me 19 orchestrions across 7 venues in 7 different countries.&lt;/p&gt;
&lt;p&gt;I haven't fact-checked them all, but the first one - at the Musical MUseum in Brentford, London - is one that I've &lt;a href="https://www.niche-museums.com/115" rel="nofollow"&gt;seen with my own eye&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;This clearly isn't a comprehensive list, but I'm not sure asking "deep research" tools for a comprehensive list really plays into their strengths. There's a limit on how many tool calls they can churn through in a single session.&lt;/p&gt;
&lt;p&gt;I don't consider the results publishable outside of a TIL post explaining what I did precisely because the information still needs to be verified.&lt;/p&gt;
&lt;p&gt;It did however highlight both &lt;a href="https://www.museumspeelklok.nl/en/" rel="nofollow"&gt;Museum Speelklok&lt;/a&gt; in Utrecht and &lt;a href="https://www.sanfilippofoundation.org/orchestrian-room.html" rel="nofollow"&gt;The Sanfilippo Foundation&lt;/a&gt; in Barrington, Illinois as two places I should make a pilgrimage to as soon as possible!&lt;/p&gt;
</content>
    <link href="https://til.simonwillison.net/llms/o4-mini-deep-research"/>
  </entry>
  <entry>
    <id>tag:til.simonwillison.net,2020-04-30:python_uv-tests.md</id>
    <title>Testing different Python versions with uv with-editable and uv-test</title>
    <updated>2025-10-09T02:47:48+00:00</updated>
    <author>
      <name>Simon Willison</name>
      <uri>https://simonwillison.net/</uri>
    </author>
    <content type="html">&lt;p&gt;A quick &lt;code&gt;uv&lt;/code&gt; recipe I figured out today, for running the tests for a project against multiple Python versions.&lt;/p&gt;
&lt;p&gt;The key command option is &lt;code&gt;uv run --with-editable .[test]&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Start with any Python project that has a &lt;code&gt;[test]&lt;/code&gt; extra defined, for example:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;&lt;span class="pl-c1"&gt;cd&lt;/span&gt; /tmp
git clone https://github.com/simonw/datasette
&lt;span class="pl-c1"&gt;cd&lt;/span&gt; datasette&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Then run &lt;code&gt;uv&lt;/code&gt; against it with a specific Python version like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;uv run --python 3.14 --isolated --with-editable &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;.[test]&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; pytest -n auto&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Here I'm using &lt;code&gt;--isolated&lt;/code&gt; to make sure nothing from any other environments sneaks in.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;--with-editable '.[test]'&lt;/code&gt; part is important - it tells &lt;code&gt;uv&lt;/code&gt; to install the current project in editable mode so that changes you make will be picked up. I first tried this using &lt;code&gt;--with '.[test]'&lt;/code&gt; and was confused as to why changes I made to the code weren't being picked up when I re-ran the tests.&lt;/p&gt;
&lt;p&gt;Datasette uses &lt;code&gt;pytest-xdist&lt;/code&gt; to run tests in parallel, so I added &lt;code&gt;-n auto&lt;/code&gt; to have it automatically use all available CPU cores.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;uv-test&lt;/h2&gt;&lt;a id="user-content-uv-test" class="anchor" aria-label="Permalink: uv-test" href="#uv-test"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;I wrote a little helper script (with &lt;a href="https://chatgpt.com/share/68e729ea-13f4-8006-8dd5-17b031ecf8eb" rel="nofollow"&gt;ChatGPT's help&lt;/a&gt;) to make this easier to remember. It uses Python 3.14 by default but you can pass a different version as the &lt;code&gt;-p&lt;/code&gt; argument. Any subsequent arguments will be passed to &lt;code&gt;pytest&lt;/code&gt;.&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/sh&lt;/span&gt;
&lt;span class="pl-c1"&gt;set&lt;/span&gt; -eu  &lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; (no pipefail in POSIX sh)&lt;/span&gt;

&lt;span class="pl-en"&gt;usage&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;Usage: uv-test [-p|--python PY_VER] [pytest args...]&lt;span class="pl-pds"&gt;"&lt;/span&gt;&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;  -p, --python  Set Python version (default: &lt;span class="pl-cce"&gt;\$&lt;/span&gt;UV_PY or 3.14)&lt;span class="pl-pds"&gt;"&lt;/span&gt;&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;  -h, --help    Show this help&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
}

PYVER=&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;span class="pl-smi"&gt;${UV_PY&lt;span class="pl-k"&gt;:-&lt;/span&gt;3.14}&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; Parse only our flags; pass the rest to pytest&lt;/span&gt;
&lt;span class="pl-k"&gt;while&lt;/span&gt; [ &lt;span class="pl-smi"&gt;$#&lt;/span&gt; &lt;span class="pl-k"&gt;-gt&lt;/span&gt; 0 ]&lt;span class="pl-k"&gt;;&lt;/span&gt; &lt;span class="pl-k"&gt;do&lt;/span&gt;
  &lt;span class="pl-k"&gt;case&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;span class="pl-smi"&gt;$1&lt;/span&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;in&lt;/span&gt;
    -p|--python)
      &lt;span class="pl-c1"&gt;shift&lt;/span&gt;
      [ &lt;span class="pl-smi"&gt;$#&lt;/span&gt; &lt;span class="pl-k"&gt;-gt&lt;/span&gt; 0 ] &lt;span class="pl-k"&gt;||&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;error: -p/--python requires a version&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;&amp;gt;&amp;amp;2&lt;/span&gt;&lt;span class="pl-k"&gt;;&lt;/span&gt; &lt;span class="pl-c1"&gt;exit&lt;/span&gt; 2&lt;span class="pl-k"&gt;;&lt;/span&gt; }
      PYVER=&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;span class="pl-smi"&gt;$1&lt;/span&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;&lt;span class="pl-k"&gt;;&lt;/span&gt; &lt;span class="pl-c1"&gt;shift&lt;/span&gt; ;;
    -h|--help) usage&lt;span class="pl-k"&gt;;&lt;/span&gt; &lt;span class="pl-c1"&gt;exit&lt;/span&gt; 0 ;;
    --) &lt;span class="pl-c1"&gt;shift&lt;/span&gt;&lt;span class="pl-k"&gt;;&lt;/span&gt; &lt;span class="pl-c1"&gt;break&lt;/span&gt; ;;
    &lt;span class="pl-k"&gt;*&lt;/span&gt;) &lt;span class="pl-c1"&gt;break&lt;/span&gt; ;;
  &lt;span class="pl-k"&gt;esac&lt;/span&gt;
&lt;span class="pl-k"&gt;done&lt;/span&gt;

&lt;span class="pl-c1"&gt;command&lt;/span&gt; -v uv &lt;span class="pl-k"&gt;&amp;gt;&lt;/span&gt;/dev/null &lt;span class="pl-k"&gt;2&amp;gt;&amp;amp;1&lt;/span&gt; &lt;span class="pl-k"&gt;||&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;error: 'uv' not found in PATH&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;&amp;gt;&amp;amp;2&lt;/span&gt;&lt;span class="pl-k"&gt;;&lt;/span&gt; &lt;span class="pl-c1"&gt;exit&lt;/span&gt; 127&lt;span class="pl-k"&gt;;&lt;/span&gt; }
&lt;span class="pl-k"&gt;if&lt;/span&gt; [ &lt;span class="pl-k"&gt;!&lt;/span&gt; &lt;span class="pl-k"&gt;-f&lt;/span&gt; pyproject.toml ] &lt;span class="pl-k"&gt;&amp;amp;&amp;amp;&lt;/span&gt; [ &lt;span class="pl-k"&gt;!&lt;/span&gt; &lt;span class="pl-k"&gt;-f&lt;/span&gt; setup.py ]&lt;span class="pl-k"&gt;;&lt;/span&gt; &lt;span class="pl-k"&gt;then&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;error: no project file found (need pyproject.toml or setup.py). Run from project root.&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;&amp;gt;&amp;amp;2&lt;/span&gt;
  &lt;span class="pl-c1"&gt;exit&lt;/span&gt; 1
&lt;span class="pl-k"&gt;fi&lt;/span&gt;

&lt;span class="pl-c1"&gt;exec&lt;/span&gt; uv run --python &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;span class="pl-smi"&gt;$PYVER&lt;/span&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; --isolated --with-editable &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;.[test]&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; -- python -m pytest &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;span class="pl-smi"&gt;$@&lt;/span&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Make it executable and put it somewhere in your PATH, then you can run it like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;uv-test&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Or for a specific Python version:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;uv-test -p 3.13&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;And for custom &lt;code&gt;pytest&lt;/code&gt; arguments:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;uv-test -k permissions&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Or combined:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;uv-test -p 3.12 -k permissions -vv&lt;/pre&gt;&lt;/div&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;Variants: tadd and radd&lt;/h2&gt;&lt;a id="user-content-variants-tadd-and-radd" class="anchor" aria-label="Permalink: Variants: tadd and radd" href="#variants-tadd-and-radd"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;For &lt;a href="https://github.com/simonw/datasette/issues/2549"&gt;Datasette issue #2549&lt;/a&gt; I found myself needing to run the test suites for &lt;em&gt;many&lt;/em&gt; different Datasette plugins against my local not-released &lt;code&gt;main&lt;/code&gt; branch of Datasette. I ended up creating two new utility scripts, chmod 755 and on my path.&lt;/p&gt;
&lt;p&gt;Here's &lt;code&gt;tadd&lt;/code&gt;, for "test against Datasette dev":&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/sh&lt;/span&gt;
uv run --no-project --isolated \
  --with-editable &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;.[test]&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; --with-editable &lt;span class="pl-k"&gt;~&lt;/span&gt;/dev/datasette \
  python -m pytest &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;span class="pl-smi"&gt;$@&lt;/span&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;And &lt;code&gt;radd&lt;/code&gt;, for "run against Datasette dev":&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;/usr/bin/env bash&lt;/span&gt;
&lt;span class="pl-c1"&gt;set&lt;/span&gt; -euo pipefail

datasette_args=()
uv_with_args=()

&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; Parse CLI: take any --with X (or --with=X) for uv; everything else -&amp;gt; passed to datasette&lt;/span&gt;
&lt;span class="pl-k"&gt;while&lt;/span&gt; [ &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;span class="pl-smi"&gt;$#&lt;/span&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;-gt&lt;/span&gt; 0 ]&lt;span class="pl-k"&gt;;&lt;/span&gt; &lt;span class="pl-k"&gt;do&lt;/span&gt;
  &lt;span class="pl-k"&gt;case&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;span class="pl-smi"&gt;$1&lt;/span&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;in&lt;/span&gt;
    --with)
      &lt;span class="pl-k"&gt;if&lt;/span&gt; [ &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;span class="pl-smi"&gt;$#&lt;/span&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;-lt&lt;/span&gt; 2 ]&lt;span class="pl-k"&gt;;&lt;/span&gt; &lt;span class="pl-k"&gt;then&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;tadd: missing value for --with&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;&amp;gt;&amp;amp;2&lt;/span&gt;
        &lt;span class="pl-c1"&gt;exit&lt;/span&gt; 2
      &lt;span class="pl-k"&gt;fi&lt;/span&gt;
      uv_with_args+=(--with &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;span class="pl-smi"&gt;$2&lt;/span&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;)
      &lt;span class="pl-c1"&gt;shift&lt;/span&gt; 2
      ;;
    --with=&lt;span class="pl-k"&gt;*&lt;/span&gt;)
      uv_with_args+=(--with &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;span class="pl-smi"&gt;${1&lt;span class="pl-k"&gt;#&lt;/span&gt;--with=}&lt;/span&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;)
      &lt;span class="pl-c1"&gt;shift&lt;/span&gt;
      ;;
    --) &lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; explicit separator: rest go to pytest_args verbatim&lt;/span&gt;
      &lt;span class="pl-c1"&gt;shift&lt;/span&gt;
      &lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; preserve original quoting/word boundaries&lt;/span&gt;
      datasette_args+=(&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;span class="pl-smi"&gt;$@&lt;/span&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;)
      &lt;span class="pl-c1"&gt;break&lt;/span&gt;
      ;;
    &lt;span class="pl-k"&gt;*&lt;/span&gt;)
      &lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; anything else goes to pytest/datasette&lt;/span&gt;
      datasette_args+=(&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;span class="pl-smi"&gt;$1&lt;/span&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;)
      &lt;span class="pl-c1"&gt;shift&lt;/span&gt;
      ;;
  &lt;span class="pl-k"&gt;esac&lt;/span&gt;
&lt;span class="pl-k"&gt;done&lt;/span&gt;

&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; Run&lt;/span&gt;
&lt;span class="pl-c1"&gt;exec&lt;/span&gt; uv run &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;span class="pl-smi"&gt;${uv_with_args[@]}&lt;/span&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; --no-project --isolated \
  --with-editable &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;.[test]&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; --with-editable &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;span class="pl-smi"&gt;$HOME&lt;/span&gt;/dev/datasette&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; \
  -- datasette &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;span class="pl-smi"&gt;${datasette_args[@]}&lt;/span&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;They both take arguments, e.g.:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;tadd -x --pdb
radd content.db -p 8004 --root&lt;/pre&gt;&lt;/div&gt;
</content>
    <link href="https://til.simonwillison.net/python/uv-tests"/>
  </entry>
</feed>
