I posted Tooting with Python on Sunday, 15 August, 2021

Spent the day goofing off with Mastodon.py

Post python mastodon

Tooting with Python

What?

Let's set up a Mastodon application with Python to read and post toots.

How?

Python is the second best tool for any job in 2021, which makes it an excellent glue language. I've been centering my site workflow around it. That means the Mastodon.py library, which I have dabbled with once or twice before.

Why?

Because I've let the #IndieWeb social aspects of this site go stale and one step to fixing that is restoring POSSE automation. The first part of that is making sure I remember how to automate posting to Mastodon.

Ok fine; get on with it

Course, you're going to need an account at a Mastodon instance. I have mine. You can find one suitable for your tastes at Mastodon instances.

Registering your application

I have 2FA enabled, so it turned out to be easier for me to set up the application in account preferences (under the "Development" section).

I entered an application name, added my Website for "Application website," and selected the scopes that are important to me for today's explorations.

read : read all your account's data

write:statuses : publish statuses

That's enough to cover today's play. I'm not creating my own full-fledged Mastodon client so I don't need every permission.

Connecting your application

import json
import os
import sys
from dataclasses import dataclass
from typing import Any, Callable, Dict

from mastodon import Mastodon
from rich.pretty import pprint

The Mastodon instance developer panel gives me the details I need to connect. I set them as workspace environment variables with direnv out of habit, but you could just as easily hard-code them in Python or define in a config file of your own.

API_BASE = os.environ.get("API_BASE")
CLIENT_KEY = os.environ.get("CLIENT_KEY")
CLIENT_SECRET = os.environ.get("CLIENT_SECRET")
ACCESS_TOKEN = os.environ.get("ACCESS_TOKEN")

From my first few attempts writing this post, I know I'll want a class to organize views for the connection.

@dataclass
class App:
    """Provides convenience methods for querying an instance and posting toots."""

    mastodon: Mastodon

Once I have a connection, I don't care about those application config details. Rather than storing them in the instance, I'll use a class method to handle the work and return my new App with only the details I do care about.

class App:

    @classmethod
    def connect(
        cls,
        client_key: str = CLIENT_KEY,
        api_base_url: str = API_BASE,
        client_secret: str = CLIENT_SECRET,
        access_token: str = ACCESS_TOKEN,
    ) -> "App":
        """Return an App connected to a specific Mastodon instance."""

        mastodon = Mastodon(
            client_id=client_key,
            api_base_url=api_base_url,
            client_secret=client_secret,
            access_token=access_token,
        )
        return cls(mastodon=mastodon)

Basic setup's done. Let's create an App and see if it worked.

if __name__ == "__main__":
    app = App.connect()
    pprint(app)
App(mastodon=<mastodon.Mastodon.Mastodon object at 0x7ff14f1e8850>)
  

So anyways, we verified that our connection works. Let's take a look at what that connection provides.

The instance

Mastodon.py provides methods specifically for reading instance details. For example, instance_health tells of if a quick health check succeeded.

app = App.connect()

if app.mastodon.instance_health():
    rich.print("Connection instance is [green]healthy[/green]")
else:
    rich.print("Connection instance is [red][b]not[/b] healthy![/red]")
    sys.exit(1)
Connection instance is healthy
   

Instance details

Most of the querying methods return a dictionary or a list of dictionaries. Mastodon.instance returns an instance dict.

I don't feel like showing every item in that dictionary, though. Let's pick a few to make a decent summary. Oh hey, and let's cache that dictionary to disk so I'm not making a fresh API query every time I check this post while I'm writing it.

def stored(func: Callable) -> Dict[str, Any]:
    def inner(*args, **kwargs):
        filename = f"{func.__name__}.json"
        rich.print(f"stored.inner for {func.__name__}")

        if os.path.exists(filename):
            with open(filename, "r") as f:
                rich.print(f"Loading data from {filename}")
                data = json.load(f)
            return data

        rich.print(f"Calling {func.__name__}")
        data = func(*args, **kwargs)

        with open(filename, "w") as f:
            rich.print(f"Writing data to {filename}")
            json.dump(data, f, indent=4, default=str)

        return data

    return inner

I can do proper memoization later. "Look for a file before you hit the server" is good enough for writing a blog post.

class App:

    @stored
    def instance(self) -> Dict[str, Any]:
        """Return a dictionary of information about the connected instance."""

        return self.mastodon.instance()

    def instance_summary(self) -> Dict[str, Any]:
        """Return a small dictionary of instance information."""

        instance = self.instance()
        fields = ["uri", "title", "short_description"]
        data = {field: instance[field] for field in fields}
        data["contact_account"] = instance["contact_account"]["display_name"]

        return data

Time to look at that instance summary.

if __name__ == "__main__":
    app = App.connect()

    if app.mastodon.instance_health():
        rich.print("Connection instance is [green]healthy[/green]")
    else:
        rich.print("Connection instance is [red][b]not[/b] healthy![/red]")
        sys.exit(1)

    pprint(app.instance_summary())
Connection instance is healthy
  stored.inner for instance
  Calling instance
  Writing data to instance.json
  {
  'uri': 'hackers.town',
  'title': 'hackers.town',
  'short_description': "A bunch of technomancers in the fediverse. Keep it fairly clean please. This arcology is for all who wash up upon it's digital shore.",
  'contact_account': 'The_Gibson'
  }
  

Reading the timelines

Mastodon's timeline methods provide different views of recent post activity, both public and private. To simplify demonstration on this public blog post, I'll stick to timeline_public.

class App:
  @stored
  def timeline_public(self) -> List[Dict[str, Any]]:
      return self.mastodon.timeline_public()

The [toot-dict][toot-dict] also contains far more information than I need, so let's summarize those like with instances.

class App:

  def timeline_summary(self) -> Dict[str, Any]:
      timeline = self.timeline_public()
      return [
          {
              "date": toot["created_at"],
              "author": toot["account"]["display_name"],
              "content": toot["content"],
          }
          for toot in timeline
      ]

Adding app.timeline_summary() to the main block:

if __name__ == "__main__":
    app = App.connect()

    if app.mastodon.instance_health():
        rich.print("Connection instance is [green]healthy[/green]")
    else:
        rich.print("Connection instance is [red][b]not[/b] healthy![/red]")
        sys.exit(1)

    pprint(app.instance_summary())
    pprint(app.timeline_summary(), max_string=80)
Connection instance is healthy
  stored.inner for instance
  Loading data from instance.json
  {
  'uri': 'hackers.town',
  'title': 'hackers.town',
  'short_description': "A bunch of technomancers in the fediverse. Keep it fairly clean please. This arcology is for all who wash up upon it's digital shore.",
  'contact_account': 'The_Gibson'
  }
  stored.inner for timeline_public
  Calling timeline_public
  Writing data to timeline_public.json
  [
      .. skipping a few ...
  {
  │   │   'date': '2021-08-15 22:24:35+00:00',
  │   │   'author': 'Endless Screaming',
  │   │   'content': '<p>AAAAAAAAAAAAAAAAAAAAH</p>'
  },
  {
  │   │   'date': '2021-08-15 22:24:43.531000+00:00',
  │   │   'author': 'Lynne',
  │   │   'content': '<p>This just touched a single topic that I’ve never heard being brought up anywh'+97
  }
  ]
  

Nice. Looks like content is in HTML format. Need to remember that if I ever make a more interesting Mastodon client.

But I'm ready to start tooting.

Writing

Mastodon write methods let us add toots, polls, replies, reblogs, faves. All that good stuff.

Let's stick with your basic toot for now.

class App:
  def status_post(self, status: str, visibility: str = "direct") -> Dict[str, Any]:
      """Post a toot to our connection, private unless we say otherwise."""

      return self.mastodon.status_post(status, visibility=visibility)
if __name__ == "__main__":
    app = App.connect()

    if app.mastodon.instance_health():
        rich.print("Connection instance is [green]healthy[/green]")
    else:
        rich.print("Connection instance is [red][b]not[/b] healthy![/red]")
        sys.exit(1)

    status_text = "Ignore me, just messing with Mastodon.py"
    app.status_post(status_text)

#[It worked!](./toot.jpg [screenshot of posted toot])

Okay my brain is fading. Should probably put away the keyboard soon.

Wrap it up

Am I done?

Well, no. I still need to turn this into a proper command line application that looks for the newest published blog post and posts a toot about it. But that's not going to happen in today's post.

I had fun, and that's the important part.

Social

Got a comment? A question? More of a comment than a question? Talk to me about this post!

Indieweb Social

Did you mention this somewhere? I'd love it if you sent me the link!

disclaimer about timing

Mentions are sent to webmention.io. I fetch the latest mentions when building the site, so I may not see your feedback right away. Especially if my site's broken, which is often the case.

Public replies and mentions might be shared on the site, but I try to do a little quality check first.

Site Links