A very simple async Response cache for FastAPI

Hello world! How’s it going out there? Monkey pox scars healing OK? Great.

My most recent project is pythondocs.xyz – real time interactive search of Python documentation.

If you haven’t checked it out yet, please take a look and let me know what you think over here.

The site is built with FastAPI and I wanted to make it as fast as possible. In particular, I wanted the home page to load almost instantly. The home page is constructed from a couple of database queries and I realised I could reduce load times by building the page once then caching it for future visitors.

But how?

Decorated service

Here’s the solution I came up with, with no external dependencies.

To start, some imports:

import asyncio
from functools import wraps

from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates

The main course is where you find the meat:

def cache_response(func):
    """
    Decorator that caches the response of a FastAPI async function.

    Example:
    ```
        app = FastAPI()

        @app.get("/")
        @cache_response
        async def example():
            return {"message": "Hello World"}
    ```
    """
    response = None

    @wraps(func)
    async def wrapper(*args, **kwargs):
        nonlocal response
        if not response:
            response = await func(*args, **kwargs)
        return response

    return wrapper

And what’s for dessert? Oh, my favorite: objects! They’ll come in handy for the examples below…

app = FastAPI()
templates = Jinja2Templates(directory="templates")

What’s going on?

The idea is that you insert the @cache_response decorator between your async function and its FastAPI path operation decorator.

Here’s a simple example:

@app.get("/json/")
@cache_response
async def json_example():
    await asyncio.sleep(2)
    return {"message": "Hello World"}

When someone navigates to /json/, the json_example function is passed into the wrapper as func.

On the first visit, the wrapper awaits func, stores its response for next time, and returns the response.

On subsequent visits, the response is returned directly, func is never called, and whatever expensive operations it contains don’t slow things down.

Template tantrum

The decorator also works with functions that have parameters, like this Jinja template route:

@app.get("/", response_class=HTMLResponse)
@cache_response
async def home_page(request: Request):
    await asyncio.sleep(2)
    return templates.TemplateResponse("hello_world.html", context={"request": request})

But beware that the first response is always stored and reused, even if called with different arguments!

Is this a terrible idea?

I don’t think it’s, like, the worst idea ever. It’s not like writing your Social Security Number on your front door.

But this is computers and there’s a million ways of doing everything, with various strengths and weaknesses.

I think these are the main downsides of this very simple approach:

  • The cached data is stored in-process and so won’t be shared with other workers.
  • There’s no way of clearing the cache (other than restarting Python).
  • A response isn’t cached until the first call completes. Additional requests received before this happens will still trigger your expensive function.

Here are some alternative approaches, which may work better for your case:

  • Cache intermediate steps in your function (rather than the whole thing), perhaps with functools.cache.
  • Use a dedicated caching service, like Redis, or a database.
  • Cache static assets with a Content Delivery Network, like Cloudflare.

Thanks for reading

More Python stuff coming soon.

Let me know if you have any comments on this post or ideas for the future!

The full code for this post is here on GitHub.

Introducing pythondocs.xyz – live search for Python documentation

Winter is long here.

It is so long that I did an accidental software development after False Spring 3 – whoops!

pythondocs.xyz demo

pythondocs.xyz

pythondocs.xyz is a web tool that provides live search results for Python’s official documentation.

Please try it out and let me know what you think!

It’s at “beta” stage, which means it works pretty well but it’s not perfect.

It’s fast and it looks good and the results are… fine.

It did, however, survive the front page of HackerNews without going above 2% CPU usage, which I think is pretty good.

The next big feature will be better search results. In particular: improved prominence of important language features, like built-in functions, and refined full text search and ordering of results.

Here’s the tech stack as it currently stands, for those interested:

  • Parser: Beautiful Soup + Mozilla Bleach
  • Database: in-memory SQLite (aiosqlite) + SQLAlchemy
  • Web server: FastAPI + Uvicorn + Jinja2
  • Front end: Tailwind CSS + htmx + Alpine.js

This is my first big FastAPI project, and over the next few weeks I’ll blog about some of the tricks I used, especially to do with performance.

Python & VS Code: make Black and organize imports work together on save

Hello world. What a stable and positive time to be alive!

But at least we can fix our code editors.

I use Black to format my Python code, and I tell VS Code to organise imports on save.

Here’s the relevant bit of VS Code’s settings.json:

{
    "python.formatting.provider": "black",
    "editor.formatOnSave": true,
    "editor.codeActionsOnSave": {
        "source.organizeImports": true
    }
}

It’s a pretty pleasant experience, mostly. But this “flapping” behaviour when I save has been bugging me for weeks:

(It’s even more annoying when you’re part way down a file and the entire screen jerks up and down.)

Clearly, Black and the organize imports thing are fighting. But what is the organize imports thing? And what’s the fix?

It turns out that VS Code uses the isort library to sort imports, and isort has a Black compatability profile.

As a Poetry user, all I had to do is add these lines to the pyproject.toml file at the root of my project:

[tool.isort]
profile = "black"

Alternatively, create a file called .isort.cfg (note the leading dot) at the root of your project, with this content:

[settings]
profile=black

No more flapping, and at least one small corner of the universe is a little more sane.

Backup / migrate Microsoft To Do tasks with PowerShell and Microsoft Graph

Update: this post has a spiritual successor – Extract Microsoft To Do steps/sub-tasks from your web browser (with Asana import example)

For more than a year, I’ve foolishly been using a developer Office 365 subscription for some personal stuff. You know, the subscription where they delete your data if “development activity” isn’t detected every few months. As such, I’ve periodically had to fake some development activity in order to keep the clock ticking.

Not a sustainable situation, and it’s time to sort it out…

For me, this involves moving data from one subscription’s OneDrive to another. I’m fairly confident that Rclone will be able to handle this – it’s an excellent bit of software.

It also means moving Microsoft To Do tasks between subscriptions. Ah.

Not so easy

I couldn’t find an easy way of backing up To Do. There is mention of an Outlook backup option in the docs, but it’s missing on my account. And To Do will happily suck in data from Wunderlist but I can’t see an equivalent to get data out. Where’s the Justice Department when you need them?

Luckily Microsoft Graph has a To Do API in preview and I was able to put together a script to do the lifting for me.

Ironically, this has involved an intense burst of real developer activity…

Enter the Dragon

The full script is over on GitHub.

It provides two functions:

  • Export-MicrosoftTodo saves every Microsoft To Do list and task to an XML file.
  • Import-MicrosoftTodo loads this XML file and restores all lists and tasks.

This is what a backup looks like:

Here’s a restore:

And this is what the client sees:

You can see that the completed status of the tasks has been copied. This is also true of created/modified dates, reminders, notes, and so on.

The script is quite long, so I won’t paste the whole thing here, but here are a few interesting bits:

Emotional support

Thankfully, for the comfort of our technology-addled minds, Microsoft To Do lets you decorate your lists with little emojis. Internally, it looks like if the first character of the name is an emoji, it gets special treatment in the UI.

I was having difficulty creating these special list names but the fix was simply to add charset=utf-8 to Invoke-RestMethod‘s ContentType:

$params = @{
    "Method"         = "Post"
    "Uri"            = ($graphBaseUri + "/me/todo/lists")
    "Authentication" = "OAuth"
    "Token"          = $accessToken
    "Body"           = @{
        "displayName" = $list.displayName
    } | ConvertTo-Json
    # utf-8 makes emojis work. Life priorities are correct.
    "ContentType"    = "application/json; charset=utf-8"
}
Invoke-RestMethod @params

Before and after:

Jason Bateman

The basic aim of the script is to retrieve data from one API endpoint and to later submit the same data to another endpoint.

I found that PowerShell’s – er – adorable magic got in the way a bit. Specifically, the JSON (de-)serialisation done by Convert*-Json / Invoke-RestMethod didn’t preserve empty properties, and the conversion to/from a DateTime object didn’t match the format expected by the API – and I couldn’t see an easy way to override this behaviour.

My solution was to use an alternative JSON parser available in .NET to grab the appropriate bit of the HTTP response, remove a few properties, and store the resulting JSON as a string, to be later POSTed back to the API verbatim:

$response = Invoke-WebRequest -Uri $uri -Authentication OAuth -Token $accessToken
# Invoke-RestMethod / ConvertFrom-Json mangles the response, which I resent,
# so we're using an alternative parser and storing the JSON as a string
# https://stackoverflow.com/a/58169326/12055271
$json = [Newtonsoft.Json.JsonConvert]::DeserializeObject($response.Content)
ForEach ($task in $json.value) {
    # Don't need ID - Graph API can generate a new one
    $task.Remove("id") | Out-Null
    # Don't need ETag
    $task.Remove("@odata.etag") | Out-Null
    $results += $task.ToString()
}

I also chose to save the exported data to disk using PowerShell’s CLI XML format – rather than JSON – as an easy way of guarantee the string stays as it is.

Token effort

The script needs an OAuth2 token in order to authenticate with your Microsoft account.

An easy way to get going (and slightly hacky but fine for personal use) is to grant yourself all Tasks.* permissions in Graph Explorer and copy its token, as demoed here:

(Thanks GoToGuy for this blog post.)

Please read the following license agreement carefully

A few notes on the design of the script:

  • It worked well for me and 5000(!) tasks, but please do your own testing. You can create a test Microsoft account with a secondary email address, or make an Azure tenant for free.
  • Tested with PowerShell 7 only. Get with the times.
  • Export-MicrosoftTodo currently backs up every task and Import-MicrosoftTodo restores every task.
  • If you run Import-MicrosoftTodo twice you’ll end up with duplicates.
  • The account used for export/import is the one that generated the OAuth token. You can backup from one account and restore to another simply by providing different tokens.
  • The script does not currently migrate linkedResources – these “represent[…] an item in a partner application related to a todoTask.” Shrug.
  • Nor does it share any lists as part of data import.
  • Currently, the script needs to be run interactively, in order to receive the OAuth token and to confirm a restore.
  • I’d be open to making improvements in these areas if there’s interest! The script could backup individual lists, for example, or backup someone else’s account (with the appropriate permissions).
  • Unfortunately, I don’t think there’s currently any way to retain list groups.

And in conclusion

Thanks for reading!

This has been a fun project and hopefully you can get some use out of the methods used or the script itself.

Start Windows Terminal in WSL2 home directory

A little tip, partly written for the benefit of future-me…

As you may know, WSL2 filesystem performance is much improved over its predecessor – but only in the virtual disk of your Linux distro. The mounted Windows disk – /mnt/c/ – is still slow for disk-heavy operations, like Git clones, because of Linux-plus-NTFS something reasons.

Because of this, the first thing I do when I open my WSL2 Ubuntu distro in Windows Terminal is change to my Linux home directory. Every time.

There must be an easier way? Yep.

From the WSL docs:

bash ~ launches the bash shell into the user’s home directory. Similar to running cd ~.

To make this the default in Windows Terminal, open the Windows Terminal Settings, find your WSL2 profile, and add “commandline”: “bash.exe ~”

{
    "guid": "{2c4de342-38b7-51cf-b940-2309a097f518}",
    "hidden": false,
    "name": "Ubuntu",
    "source": "Windows.Terminal.Wsl",
    "commandline": "bash.exe ~"
}

(Note that the “source” line now needs a trailing comma.)

From this:

To this: