Concisely show the status of a Beeminder goal

Image preview of Beeminder plugin.

Open on GitHub
#!/usr/bin/env -S PATH="${PATH}:/opt/homebrew/bin:/usr/local/bin" python3
# -*- coding: utf-8 -*-

# Metadata allows the plugin to show up in the xbar app and website.
#  <xbar.title>Beeminder</xbar.title>
#  <xbar.version>v0.1</xbar.version>
#  <>Tom Adamczewski</>
#  <>tadamcz</>
#  <xbar.desc>Concisely show the status of a Beeminder goal</xbar.desc>
#  <xbar.image></xbar.image>
#  <xbar.dependencies>python</xbar.dependencies>

# <xbar.var>string(VAR_AUTH_TOKEN=None): Your Beeminder auth token</xbar.var>
# <xbar.var>string(VAR_GOAL_SLUG='exercise'): Your Beeminder goal slug e.g. 'exercise' for</xbar.var>
# <xbar.var>string(VAR_GOAL_EMOJI='🏋️'): Emoji to represent your goal (will appear in the menu bar)</xbar.var>

import os
import json
import time
from urllib.request import urlopen, Request
from urllib.parse import urlencode
from urllib.error import URLError

# Script-level variables
GOAL_SLUG = os.environ["VAR_GOAL_SLUG"]

# Check variables
if not AUTH_TOKEN:
    raise Exception("Please set your Beeminder auth token in the script variables")
if not GOAL_SLUG:
    raise Exception("Please set your Beeminder goal slug in the script variables")
if not GOAL_EMOJI:
    raise Exception("Please set your goal emoji in the script variables")

# Get data

# Docs: "Since appending an access_token to the request uniquely identifies a user, you can alternatively make the request to /users/me.json (without the username)."
API_URL = f''
params = dict(auth_token=AUTH_TOKEN, datapoints_count=1, associations=True)
url_with_params = f'{API_URL}?{urlencode(params)}'

retries = 13
backoff_factor = 0.1
data = None

for i in range(retries):
        response = urlopen(url_with_params)
        if response.status != 200:
            raise URLError(f'Error fetching data: status {response.status}')
        data = json.loads(
        goals = data['goals']
        resolved_username = data['username']
    except URLError as e:
        if i < retries - 1:
            time.sleep(backoff_factor * (2 ** i))
            raise Exception(f'Error fetching data: {e}')

# Select goal
goal_emoji = GOAL_EMOJI
goal_slug = GOAL_SLUG
chinups = next(filter(lambda goal: goal['slug'] == goal_slug, goals))
goal = chinups

# Assemble output
color_emojis = {
    "green": "🟢",
    "blue": "🔵",
    "orange": "🟠",
    "red": "🔴",

goal_url = f'{resolved_username}/{goal["slug"]}'
color_emoji = color_emojis[goal["roadstatuscolor"]]
message = goal['limsumdays']

# Abbreviate message to take up less space in menu bar
message = message.replace("due ", "").replace("days", "d").replace("day", "d")

output = []
output.append(f"{goal_emoji}{color_emoji}{message}")  # Shown in menu bar
output.append(f"{goal_url} | href={goal_url}")  # Shown in dropdown