This week's dispatches from the Ministry of Intrigue
Hello, faithful reader.
We published the following fresh dispatches this week:
Playing with Rye and Mastodon.py
Feb. 23, 2024, 12:08 p.m.

Another day, another silly code project.
The Problem #
Here’s my scenario:
I produce a weekly podcast [1], which requires a lot of audio effort.
When episodes release, we do social media posts in our Discord and on our Mastodon, Instagram, and YouTube accounts.
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 Dice [2]. 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 parter 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 #
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 growth[5], 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 #
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 dhdtooter[
6].
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.

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

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)

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.")

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!
It’s pretty cool. If you’re into Actual Play TTRPG podcasts, you should listen to it. Now. ↩︎
Use code “ExplorersWanted” at checkout for 10% off! ↩︎
For a brief period, Mammoth supported this, but they’ve since dropped the feature. sigh ↩︎
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. ↩︎
A topic for a future post at some point. ↩︎
You can pry the “toot” terminology from my cold, dead fingers. ↩︎
All hail the one true timezone. ↩︎
Quote: Jacob Kaplan-Moss on Paying Open Source Maintainaers
Feb. 20, 2024, 12:52 p.m.
Yes, the fact that people have to choose between writing open source software and affording decent healthcare is a problem deeply rooted in our current implementation of zero-sum capitalism, and not at all a problem that can be laid at the feet of the free software movement. The dream is that society and governments will recognize free software as the public good that it most certainly is and fund it appropriately. And also fix healthcare, and housing access, and public transportation, and the social safety net, and and and …
I am absolutely one million percent on board with this vision, but this shit ain’t gonna happen overnight. Indeed, I doubt it’ll happen in our lifetimes if at all.
We have to accept the world as it is – even if it’s not the world we want. This means we have to be okay with the idea that maintainers need to be paid. Far too often I see arguments like: “maintainers shouldn’t be paid by private companies because the government should be supporting them.” Sure, this sounds great – but governments aren’t doing this! So this argument reduces to “open source maintainers shouldn’t be paid”. I can’t get on board with that.
— Jacob Kaplan-Moss, Paying people to work on open source is good actually
And that's it!
Grave dust and falling leaves.