Skip to main content
  1. Dispatches/

Playing with Rye and Mastodon.py

·1942 words·10 mins
Articles Python Mastodon Rye
Daniel Andrlik
Author
Daniel Andrlik lives in the suburbs of Philadelphia. By day he manages product teams. The rest of the time he is a podcast host and producer, writer of speculative fiction, a rabid reader, and a programmer.

Another day, another silly code project.

The Problem>

The Problem #

Here’s my scenario:

  1. I produce a weekly podcast1, which requires a lot of audio effort.
  2. When episodes release, we do social media posts in our Discord and on our Mastodon, Instagram, and YouTube accounts.
  3. I have a full-time job that requires a lot of international travel, so I’m not always available when those posts should happen.

Now, this problem can mostly be solved with existing scheduling tools. For example, the majority of our Instagram, YouTube, and Mastodon announcements can be scheduled in Buffer on a free plan. Discord announcements can be set up via Message Scheduler. Even still, there are two posts per week that cannot be accommodated with existing tools, and both have to do with promoting our affiliate sponsor, Die Hard Dice2. Simply put, both Instagram and our Mastodon server have specific rules around paid sponsorships/deals. In the case of Instagram, you must attach your brand partner to the post, and in the case of our Mastodon instance, we need to post the frequent deal posts with setting “Unlisted” so as not to muck up the local timeline. Unfortunately, Buffer cannot handle either of these scenarios.

Instagram is easy enough to solve for, as the iOS app allows me to schedule the post and add the partner there. It’s a little less convenient than doing it in the same place as everything else, but it beats trying to hassle with the Instagram publishing API. Mastodon does have relatively robust, but little known, support for scheduled posts. However, almost no apps (including the official web app!) support doing this.3

Possible Solution>

Possible Solution #

At one point, I had considered building a social media scheduling system that could support all these edge cases and handle new services as modular backends. Looking back at it, it’s clear to me that I was trying to do too much with it.4 Given that I’m less convinced than ever that social media has much impact on a podcast’s growth5, and therefore treat the posts more as a courtesy to listeners (and an obligation to sponsors), I didn’t want to invest much more time in creating or maintaining it.

It was right around this time that I came across this post from Jeff Triplett on the joys of semi-automation.

According to the ninety-ninety rule, the final 10 percent of code needed to automate a task completely can take up 90 percent of the development time. This disproportionate effort is why I recommend that everyone consider semi-automating tasks and skipping the last 10%.

Jeff Triplett, The Power of Semi-Automation: Why Less Can Be More

Reading it, I realized that I didn’t need to merge all my tools into a singular all-encompassing solution, I only needed a simple utility to handle that one edge case!

The Work>

The Work #

I had been looking for an excuse to try out Rye, an opinionated Python project/package management solution written in Rust, and given the small scope, this seemed to fit the bill. So I installed Rye, and ran rye init dhdtooter6.

I knew I wanted to create a pretty, but very simple CLI for the interface so I turned to click and Mastodon.py.

It was easy enough using the Mastodon.py docs to register an app and authenticate my user account against it. The library supports persisting the credentials to .secret files that are easy to exclude from source control, which makes things relatively easy for a utility script without mucking about with environment variables.

Since the sponsorship posts are almost always the same, it was preferable to define some constants representing the template post text, the default path to the media file, and default alt text for said file. I also knew that the majority of the time, the only thing I would need to change was the scheduled time for the post. But, with click it was relatively trivial to add command line flags to customizing any part of the post as needed.

The first thing I knew I would have to deal with was timezones. I’m often traveling and I can’t rely on my current computer timezone to parse the scheduled time, and I didn’t want to have to manually do timezone math in my head either. This conveniently also gave me an excuse to play with timezones without the use of pytz for the first time. For giggles, I decided I wanted to provide a hook to override a timezone in addition to using a predefined default for US/Eastern time.7 I’m sure there’s more efficient ways to do this, but for something this small, who gives a shit.

from datetime import datetime, timedelta
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError


def get_timezone(tzname: str | None) -> ZoneInfo:
"""Try and fetch a timezone based on the name supplied by the user, default otherwise."""
try:
    tz = ZoneInfo(tzname) if tzname is not None else DEFAULT_TIMEZONE
except ZoneInfoNotFoundError as zinf:
    raise ValueError(
        f"Your specified time zone of {tzname} was not found."
    ) from zinf
return tz


def get_datetime(
        date_str: str, timezone: ZoneInfo = DEFAULT_TIMEZONE, future_limit_minutes: int = 10
    ) -> datetime:
        """Given a valid string representation of a datetime, parse it into a timezone aware datetime."""
        if not timezone:
            timezone = DEFAULT_TIMEZONE
        datestr_re = re.compile(r"^(\d{4})-(\d{2})-(\d{2})(T|\s+)(\d{2}):(\d{2})$")
        if not datestr_re.match(date_str):
            raise ValueError(
                f"Your entry of {date_str} is not valid. Datetimes must be expressed as YYYY-mm-dd HH:MM!"
            )
        date_str = re.sub(r"\s+", "T", date_str)
        naive_datetime = datetime.strptime(date_str, "%Y-%m-%dT%H:%M")
        aware_datetime = datetime(
            year=naive_datetime.year,
            month=naive_datetime.month,
            day=naive_datetime.day,
            hour=naive_datetime.hour,
            minute=naive_datetime.minute,
            tzinfo=timezone,
        )
        if future_limit_minutes > 0 and aware_datetime < datetime.now(
            tz=timezone
        ) + timedelta(minutes=future_limit_minutes):
            raise ValueError(
                f"Scheduled time must be at least {future_limit_minutes} minutes in the future!"
            )
        return aware_datetime

I wrote some tests using pytest’s excellent parametrize feature, and once I was satisfied my inputs weren’t going to create madness we were good to go.

Because I was feeling like a fancy boy, I went ahead and added rich to the project and then wrote a command that allowed you to input a simplified version of an ISO datetime, but also override any of the other default post settings if needed.

import click

from mastodon import Mastodon
from rich.console import Console
from rich.progress import Progress
from rich.table import Table


VISIBILITY_CHOICES = ["private", "direct", "unlisted", "public"]


def get_auth_client(
    client_id: str = "ewposter_clientcred.secret",
    user_auth: str = "ewposter_usercred.secret",
) -> Mastodon:
    mastodon_client = Mastodon(client_id=client_id, access_token=user_auth)
    return mastodon_client
    

def get_base_table(title: str, show_lines: bool = False) -> Table:
        """Gets a rich table to add rows of data for scheduled statuses."""
        table = Table(title=title, show_lines=show_lines)
        table.add_column("Queue Id", justify="right", style="cyan", no_wrap=True)
        table.add_column("Scheduled", justify="right", style="magenta", no_wrap=True)
        table.add_column("Text", justify="left", style="green")
        table.add_column("Visibility", justify="center", style="blue")
        table.add_column("CW", justify="left", style="magenta")
        table.add_column("Media Ids", justify="right", style="cyan")
        return table

    

@click.group()
def dhdpost():
    pass
    
@dhdpost.command(name="post")
    @click.option(
        "--debug", is_flag=True, default=False, help="Display post data but don't send."
    )
    @click.option(
        "--tzname",
        type=str,
        help="Override the timezone used by name, e.g. America/Chicago",
    )
    @click.option("--text", default=DHD_POST_TEMPLATE, help="Override the text of the post")
    @click.option(
        "--media",
        type=click.Path(exists=True),
        default=DHD_DEFAULT_IMAGE,
        help="Override the media file used.",
    )
    @click.option(
        "--alt-text",
        type=str,
        default=DHD_ALT_TEXT_TEMPLATE,
        help="Override the alt text for the media",
    )
    @click.option(
        "--visibility",
        type=click.Choice(VISIBILITY_CHOICES),
        default="unlisted",
        show_default=True,
        help="Override the default visibility of the post.",
    )
    @click.option(
        "--cw",
        type=str,
        default="dice deal",
        show_default=True,
        help="Override the cw text",
    )
    @click.argument("schedule_time")
    def dhd_post(
        schedule_time: str,
        tzname: str | None,
        text: str = DHD_POST_TEMPLATE,
        media: str = DHD_DEFAULT_IMAGE,
        alt_text: str = DHD_ALT_TEXT_TEMPLATE,
        visibility: str = "unlisted",
        cw: str = "dice deal",
        debug: bool = False,
    ) -> None:
        """Creates a post for the specified scheduled time. Provide this in simple ISO format, i.e. 2023-11-29T13:34"""
        try:
            tz = get_timezone(tzname)
        except ValueError as ve:
            click.echo(
                str(ve), err=True
            )
            exit(1)
        try:
            schedule_datetime = get_datetime(schedule_time, tz)
        except ValueError as de:
            click.echo(str(de), err=True)
            exit(1)
        if re.match(r"^\s+$", alt_text):
            alt_text = ""
        if media is not None and not alt_text:
            click.echo("You must include alt text for any media", err=True)
            exit(1)
        console = Console()
        if debug:
            table = Table(title="You requested this post", show_lines=True)  # Use a pretty table
            table.add_column("Parameter", no_wrap=True, style="cyan")
            table.add_column("Value", style="magenta")
            table.add_row("Scheduled time", str(schedule_datetime))
            table.add_row("Media", media)
            (table.add_row("Alt Text", alt_text),)
            table.add_row("Visibility", visibility)
            table.add_row("CW", cw)
            table.add_row("Text", text)
            console.print(table)
            exit(0)
        with Progress() as progress:  # Show a fancy progress bar
            task_id = progress.add_task(description="[cyan]Starting publish...", total=3)
            client = get_auth_client()
            progress.update(task_id=task_id, completed=1)
            media_dict: dict[str, Any] | None = None
            if media is not None:
                progress.update(task_id=task_id, description="[cyan]Uploading media...")
                media_dict = client.media_post(media_file=media, description=alt_text)
            progress.update(
                task_id=task_id, completed=2, description="[cyan]Scheduling post..."
            )
            status_dict = client.status_post(
                status=text,
                media_ids=media_dict,
                scheduled_at=schedule_datetime,
                visibility=visibility,
                spoiler_text=cw,
                language="en",
            )
            progress.update(task_id=task_id, completed=3)
        table = get_base_table(title="Your scheduled post")
        table.add_row(
            str(status_dict["id"]),
            str(status_dict["scheduled_at"].astimezone(tz=tz)),
            status_dict["params"]["text"],
            status_dict["params"]["visibility"],
            status_dict["params"]["spoiler_text"],
            str(status_dict["params"]["media_ids"]),
        )
        console.print(table)

I wanted the help menu to be extra pretty so I added rich-click and made a quick change:

import rich_click as click

Now, when you run help for the post command you get output like the below.

A pretty formatted help menu enumerating all the options for the script being outputted in response to <code>rye run dhdpost post --help</code>
So pretty

And when I need to make a post it goes like this.

A recording of the posting process showing the progress dashboard responding to tasks such as uploading media and scheduling the post, resulting in a pretty table view of the scheduled post.
Note that this scheduled post was already deleted. The id shown is worthless now.

So that’s cool. But what if I want to review what I’ve already got scheduled?

@dhdpost.command()
@click.option(
    "--tzname",
    default=None,
    help="Name of a timezone to use when displaying date times",
)
def get_posts(tzname: str | None = None):
    """Fetches the list of existing scheduled posts for the user."""
    tz: ZoneInfo | None = None
    try:
        tz = get_timezone(tzname)
    except ValueError as ve:
        click.echo(str(ve), err=True)
    client = get_auth_client()
    statuses = client.scheduled_statuses()
    console = Console()
    if not statuses:
        console.print("No scheduled statuses")
        exit(0)
    table = get_base_table(title="Your scheduled posts", show_lines=True)
    for status in statuses:
        scheduled = (
            status["scheduled_at"]
            if not tz
            else status["scheduled_at"].astimezone(tz=tz)
        )
        table.add_row(
            str(status["id"]),
            str(scheduled),
            status["params"]["text"],
            status["params"]["visibility"],
            status["params"]["spoiler_text"],
            str(status["params"]["media_ids"]),
        )
    console.print(table)

The output of the get-posts subcommand showing a table of four scheduled posts
Some scheduled posts.

Okay, but what if I want to update the time on one of these posts?

@dhdpost.command("update")
@click.argument("queue_id", type=int)
@click.argument("schedule_time", type=str)
@click.option(
    "--tzname", default=None, help="Override the default timezone, e.g. America/Chicago"
)
def update_scheduled_status_time(
    queue_id: int, schedule_time: str, tzname: str | None = None
):
    """Update the scheduled time for a previously scheduled status."""
    try:
        tz = get_timezone(tzname=tzname)
    except ValueError as ve:
        click.echo(str(ve), err=True)
        exit(1)
    try:
        new_scheduled = get_datetime(schedule_time, tz)
    except ValueError as de:
        click.echo(str(de), err=True)
        exit(1)
    client = get_auth_client()
    status_dict = client.scheduled_status_update(queue_id, scheduled_at=new_scheduled)
    click.echo(
        f"Status with id of {queue_id} updated to post at {status_dict['scheduled_at']}"
    )

How about deleting a scheduled post?

@dhdpost.command("delete")
@click.argument("queue_id", type=int)
def delete_scheduled_post(queue_id: int):
    """Deletes a previously scheduled post."""
    client = get_auth_client()
    client.scheduled_status_delete(queue_id)
    click.echo("Status deleted.")

Screenshot of the help menu showing off all the defined subcommands for dhdpost
The help menu for the whole app. It all comes together.

Final Thoughts>

Final Thoughts #

At some point I should add some better handling for Mastodon errors, but since it’s a utility script that’s just for me, I didn’t feel like spending the effort to avoid the off chance of an occasional exception hitting the terminal. After all, I’d want the program to exit at that point anyway.

A couple takeaways:

  • Mastodon.py is an incredible library for working with the Mastodon API.
  • Rye is pretty cool. I don’t know if I’d replace my goto of Hatch as a default yet, but the ergonomics are definitely there.
  • Python remains an incredibly productive language for me to work in. I spent more time writing this post than it took me to write the whole application!

On to the next project!


  1. It’s pretty cool. If you’re into Actual Play TTRPG podcasts, you should listen to it. Now. ↩︎

  2. Use code “ExplorersWanted” at checkout for 10% off! ↩︎

  3. For a brief period, Mammoth supported this, but they’ve since dropped the feature. sigh ↩︎

  4. That project was trying to allow a user to schedule:

    • Text posts
    • Media posts (also for Instagram images and reels)
    • Threads containing the above
    • Boosts
    • Differentiate between allowed media types
    • Securely manage user API keys
    • Show them in a calendar view

    It was nuts. ↩︎

  5. A topic for a future post at some point. ↩︎

  6. You can pry the “toot” terminology from my cold, dead fingers. ↩︎

  7. All hail the one true timezone. ↩︎

Related

Human Readable RSS Feeds With Pelican
·692 words·4 mins
Articles Python Rss Pelican
A few months ago, I came across this post by Simone Silvestroni explaining how they implemented a styled and human-readable RSS feed for their Jekyll-powered blog. I really liked the idea and wanted to implement it on my own site for browsers that don’t automatically offer to subscribe to a given feed.
Migrating a Django application from Heroku to AWS Elastic Beanstalk
·3467 words·17 mins
Articles Development Heroku Aws Elastic Beanstalk Django Python
Here’s the scenario you find yourself in: you’ve built and deployed a cool little Django app, and successfully deployed it on Heroku. You’ve followed all the great advice out there and are using django-environ to help manage all the various configuration variables you have to retrieve from your Heroku dynos.
Riding the Mastodon
·1713 words·9 mins
Articles Personal Social Media Twitter Mastodon Open Source
Reader, I did it again. I joined another social network. I know, I know. I should know better, right? But this time feels different. There’s something special about this one. Maybe it’s simply the contrast from Facebook and Twitter that makes it so appealing, but I can’t help but feel there is great promise in Mastodon.