Today, I learned that PyScript exists. PyScript is, at its core, some rigging to make Pyodide, a WebAssembly port of the CPython Python interpreter, real easy to use as a proper client-side web scripting tool. Just include one <script> tag and now <script type="py"> is a valid thing you can have in your website. Put Python code inside of it, interact with the DOM, the works. I think that rules and/or is kind of horrifying, depending. Obviously, for me, the immediate question was: Can this run Mastodon.py? And the answer is: Yes, very competently.

Some initial snags:

  • I didn’t understand at first how to get a Python module loaded, and messed around a bunch until I finally understood that PyScript comes with pip included (well, okay, not literally pip - it uses something called micropip and will grab the package and depends from PyPi, and works as long as they’re pure python packages, more complex things will not work). Yes, it will just install the module plus dependencies from PyPi on page load. This is, again, equal parts awesome and horrible.
  • To do HTTP requests, you need to import another module that will patch Pythons requests library to use XMLHttpRequest. Once you know, that works perfectly, though, and is just one import and one line to patch.
  • At first, all my requests were failing with an 422 Unprocessable Entity response from Mastodon. I spent some time trying to figure out whether my parameters were set wrongly, or maybe being passed in some incompatible or badly escaped way (which is what that would usually indicate), until I finally figured out that this response was due to the “Origin” header. I was testing with a local file, which makes the Origin be null. That causes Mastodon (or nginx - in any case, something in the stack) to refuse the request and return status 422. Once I put the website on an actual server, things worked perfectly right away.

With that sorted out, the first step was to just auth. I started with a hardcoded access token, but wait, this is just a website on a server, and the browser knows the query parameters right, so can we do OAuth entirely inside a website without any help from server side? Sure:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
# ... html rigging omitted ... 

import pyodide_http
pyodide_http.patch_all()

from pyscript import window, document, display
from mastodon import Mastodon
import urllib

# Settings
SCOPES = ["read", "write"]
APP_NAME = "PyScriptMastoDemo"
REDIRECT_URI = window.location.href
INSTANCE_URL = "https://mastodon.social/" # Sorry, hardcoded

# Local storage
def store(k, v):
    window.localStorage.setItem(k, v)
def load(k):
    return window.localStorage.getItem(k)
client_id = load("masto_client_id")
client_secret = load("masto_client_secret")
access_token = load("masto_access_token")

# Parse query parameters, then clean up the URL
params = urllib.parse.urlparse(window.location.href)
query = urllib.parse.parse_qs(params.query)
code_list = query.get("code", [])
oauth_code = code_list[0] if code_list else None
window.history.pushState("", "", params.path)

# If we don't have a client ID or secret, register a new app
if not load("masto_client_id") or not load("masto_client_secret"):
    app_info = Mastodon.create_app(APP_NAME, scopes=SCOPES, redirect_uris=
        [REDIRECT_URI], api_base_url=INSTANCE_URL)
    store("masto_client_id", app_info[0])
    store("masto_client_secret", app_info[1])

# If we do not have an access token, log in
if not access_token:
    if not (oauth_code and client_id and client_secret and INSTANCE_URL):
        # OAuth flow start: Generate an auth URL and send the user there 
        tmp_api = Mastodon(client_id=client_id, client_secret=client_secret, 
            api_base_url=INSTANCE_URL)
        auth_url = tmp_api.auth_request_url(client_id=client_id, 
            redirect_uris=REDIRECT_URI, scopes=SCOPES)
        # effectively a return statement, since we leave to a different page
        window.location.href = auth_url 
    else:
        # OAuth flow end: User has returned from the auth URL after 
        # authorizing our app, exchange code for access token
        tmp_api = Mastodon(client_id=client_id, client_secret=client_secret, 
            api_base_url=INSTANCE_URL)
        token = tmp_api.log_in(code=oauth_code, redirect_uri=REDIRECT_URI, 
            scopes=SCOPES)
        store("masto_access_token", token)
        access_token = token
        window.location.href = params.path

# At this point we have an access token and can post
api = Mastodon(access_token=access_token, api_base_url=INSTANCE_URL)
display(api.status_post("Posting from a website!", visibility="direct"))

# ... html rigging omitted ... 

And there we go: That’s Mastodon.py, fully inside a web page, letting you log into your account and post something (if you are on mastodon.social - sorry, for the basic sample, it’s hardcoded)! You can’t get much more SPA than that! And you can log in with confidence: The server hosting the web page never even sees your credentials1, so even if I was intent on acting maliciously (I am not), I couldn’t do anything, and if someone else got in, there is no database for them to leak!

So how far can we go with this? Well, here is a reasonably complete, mostly working single page web client for Mastodon. You can enter your instance URL, log in, send posts, look at posts and notifications, favourite, boost, reply, new posts and notifications load automatically. It’s certainly not the nicest, god knows I am Not A Frontend Guy, even when asking sparkly autocomplete to fill in the blanks for me, but it’s perfectly functional. You could use social media like this, if you wanted to. The streaming API, unfortunately, would need some work to get running - the threading module, which it uses internally (at least in the useful mode where it doesn’t block) isn’t supported by Pyodide (yet?), so you’d have to work around that, which is maybe possible using WebWorkers, but that’s certainly not a quick “haha wait this actually works?” type task. Getting attachments to post would also require some more hooking things up. But everything else should basically Just Work.

A very half baked, but functional, Mastodon web interface, using in-browser Mastodon.py

One open question here is: Why? Mastodon already comes with two perfectly fine web interfaces, and there’s more to choose from if you want them. Python doesn’t seem like the obvious choice for writing one, and the one I wrote as a test is obviously more of a high effort shitpost than anything intended for serious, continous use. But I think that there is quite a bit of space for simple tools that maybe currently only exist as a Python script you’d have to figure out how to run, because a lot of people would rather write Python than JavaScript. Stuff like that could very easily just also be a webpage using this. And that’s potentially pretty neat!

As for myself, I threw something a bit more meta together that I’ve wanted to have for a while: A Mastodon.py API explorer! Which is much nicer now, thanks to the extensive documentation and types for all fields! And with Mastodon.py running in the browser, is actually reasonably safe to do, because I am not executing any Python code on my server! Maybe it’ll be useful for people wanting to play around with Mastodon.py or with the Mastodon API in general!

A web application that lets the user type in a line of Python, using a pre-logged-in Mastodon.py, which then prints out the result in a nice way, and with docs on hover.

P.S.: If you want the source code for any of these… you can just view-source the HTML page! That is quite literally all there is (links may not be clickable, browsers don’t like you linking to this):


  1. It does see the OAuth auth code, but that code is useless without the client id and secret, which are only ever sent to the instance you’re logging into.