DEV Community

Adarsh Punj
Adarsh Punj

Posted on • Originally published at pythongasm.com

Create a menubar app for macOS, just using Python

Introduction

While Python is great for building a lot of things, macOS apps are certainly not one of them. I was wondering if it’s possible to build a menu bar app for macOS using Python. I found out that it’s not only possible — it’s “ridiculously uncomplicated”.

In this tutorial, we’ll be building a realtime macOS app for stock prices — all using Python.

Stock App
Let’s start by installing the dependencies.

rumps
requests
py2app
Enter fullscreen mode Exit fullscreen mode

Rumps

Rumps generates PyObjC apps (specifically menubar apps) from simple python code. To test the rumps module, run the following code:

import rumps
def hello(sender):
    print(f"Hello from {sender.title}")

app = rumps.App("Hello World")
app.menu = [rumps.MenuItem("Weird Menu Item",callback=hello)]
app.run()
Enter fullscreen mode Exit fullscreen mode

The hello() function is executed when the menu item is clicked.

>>> Hello from Weird Menu Item
Enter fullscreen mode Exit fullscreen mode

To add more menu items, we can just add more elements to the app.menu list. The argument sender represents the MenuItem that has set the callback.

A cleaner way to get the same result is by using rumps.clicked decorator:

@rumps.clicked("Weird Menu Item")
@rumps.clicked("Saner Menu Item")
def hello(sender):
    print(f"Hello from {sender.title}")

app = rumps.App("Hello World")
app.run()
Enter fullscreen mode Exit fullscreen mode

We will stick to the decorators approach for rest of the tutorial.

Stock price data

There are many sources to get stock quotes. We will be using Finnhub’s API (auth key based, free to use).

GET https://finnhub.io/api/v1/quote?symbol=AAPL&token=YOUR_API_KEY
Enter fullscreen mode Exit fullscreen mode

Sample response:

{
   "c":119.49,
   "h":119.6717,
   "l":117.87,
   "o":119.44,
   "pc":119.21,
   "t":1605436627
}
Enter fullscreen mode Exit fullscreen mode

You can register at https://finnhub.io/register for a free API key. At the time of writing this the rate limit is 60 requests/minute.

Building the StockApp

Initiate a class named “StockApp” as a subclass of rumps.App class, and add some menu items using the rumps.clicked decorator:

import rumps
class StockApp(rumps.App):
    def __init__(self):
        super(StockApp, self).__init__(name="Stock")

    @rumps.clicked("MSFT")
    @rumps.clicked("AAPL")
    def getStock(self, sender):
        self.title = f"{sender.title}"

if __name__ == '__main__':
    StockApp().run()
Enter fullscreen mode Exit fullscreen mode

Now it’s time to integrate StockApp with Finnhub API. Let’s tweak getStock() like this:

import requests
import rumps

class StockApp(rumps.App):
    def __init__(self):
        super(StockApp, self).__init__(name="Stock")

    @rumps.clicked("MSFT")
    @rumps.clicked("AAPL")
    def getStock(self, sender):
        response = requests.get(f"https://finnhub.io/api/v1/quote?symbol={sender.title}&token=YOUR_API_KEY")

        stock_data = response.json()['c']
        self.title = f"{sender.title}:{stock_data}"

if __name__ == '__main__':
    StockApp().run()

Enter fullscreen mode Exit fullscreen mode

The getStock() method updates the title when you select a stock symbol from the menu.
However, we don’t want to get the price by a click event. We need a function to continuously update the price of the selected stock, say after every few seconds.

To do this rumps has a Timer class, and you can decorate a function with rumps.timer() to set a timer on the function.

@rumps.timer(1)
def do_something(self, sender):
   # this function is executed every 1 second
Enter fullscreen mode Exit fullscreen mode

At the launch, we can set some default menu item, say “AAPL”. This option can be changed with a click event while the timer-decorated function will keep updating the price for the currently selected menu item.

@rumps.clicked("AAPL")
@rumps.clicked("MSFT")
def changeStock(self, sender):
   self.stock = sender.title

@rumps.timer(5)
def updateStockPrice(self, sender):
   # fetch stock quote and update title
Enter fullscreen mode Exit fullscreen mode

Not to complicate this but since the app will be sending network requests, we’d need to handle API requests on a different thread so the app UI keeps running while the request is in process.

import threading

@rumps.timer(5)
def updateStockPrice(self, sender):
   thread = threading.Thread(target=self.getStock)
   thread.start()

def getStock(self):
    # code to send API request 
Enter fullscreen mode Exit fullscreen mode

Putting it all together

Here’s how it will look at full implementation. We have added icons, made the title more catchy, and added a functionality for user input (using rumps.Window).

import threading
import requests
import rumps

class StockApp(rumps.App):
    def __init__(self):
        super(StockApp, self).__init__(name="Stock")

        self.stock = "AAPL"
        self.icon = "icon.png"
        self.API_KEY = "YOUR_API_KEY"

    @rumps.clicked("Search...")
    @rumps.clicked("MSFT")
    @rumps.clicked("TSLA")
    @rumps.clicked("NFLX")
    @rumps.clicked("FB")
    @rumps.clicked("AAPL")
    def changeStock(self, sender):
        if sender.title!="Search...":
            self.title = f" 🔍 {sender.title}"
            self.stock = sender.title
        else:

            # Launches a rumps window for user input
            window = rumps.Window(f"Current: {self.stock}","Search another stock")
            window.icon = self.icon
            response = window.run()
            self.stock = response.text

    @rumps.timer(5)
    def updateStockPrice(self, sender):
        thread = threading.Thread(target=self.getStock)
        thread.start()

    def getStock(self):
        response = requests.get(f"https://finnhub.io/api/v1/quote?symbol={self.stock}&token={self.API_KEY}")

        if response.status_code!=200:
            self.title = "API Error."
            return

        stock_data = response.json()

        current_price = stock_data['c']
        previous_close = stock_data['pc']
        change = current_price-previous_close

        try:
            changePercentage = abs(round(100*(change/previous_close), 2))

            if change<0:
                marker = "🔻"
            else:
                marker = "🔺"

            self.title = f" {self.stock}: {str(response.json()['c'])} {marker}{changePercentage}%"

        # Finnhub returns 0 for non-existent symbols
        except ZeroDivisionError:
            self.title = "Invalid symbol, set to AAPL"
            self.stock = "AAPL"

if __name__ == '__main__':
    StockApp().run()
Enter fullscreen mode Exit fullscreen mode

To run the app you will need to have an “icon.png” file in the same directory. You can download it from the links below or else just remove the icon from the program. Also, don’t forget to assign your Finnhub API key to self.API_KEY.

Download icon.png
Download icon.icns
Converting it to .app
Now that the app is ready, we just need to generate a shippable macOS app. We can use py2app to do this.
You need to have a setup.py file in the same directory. Other than that you can also add an icon for your application. macOS app icons have the file type .icns

StockApp
  |__ app.py
  |__ setup.py
  |__ icon.png
  |__ icon.icns
Enter fullscreen mode Exit fullscreen mode

The setup.py file:

from setuptools import setup

APP = ['app.py']
DATA_FILES = ['icon.png']
OPTIONS = {
    'argv_emulation': True,
    'iconfile': 'icon.icns',
    'plist': {
        'CFBundleShortVersionString': '1.0.0',
        'LSUIElement': True,
    },
    'packages': ['rumps','requests']
}

setup(
    app=APP,
    name='Stock',
    data_files=DATA_FILES,
    options={'py2app': OPTIONS},
    setup_requires=['py2app'], install_requires=['rumps','requests']
)
Enter fullscreen mode Exit fullscreen mode

Finally run the setup:

python setup.py py2app
Enter fullscreen mode Exit fullscreen mode

You can find your in the newly-created dist folder. Open the app, and see it in action!
If you are getting an error at the runtime, launch the app via terminal so you can go through the traceback.

open Stock.App/Contents/MacOS/Stock
Enter fullscreen mode Exit fullscreen mode

Conclusion

As we have seen it’s easy to create simple menu bar apps like this. A whole lot of things can be built on top of this as it gives you the power to trigger Python functions so easily. We can make a music controller, a server monitor, to see if a program is running, stopwatch, CPU meters, flight position trackers, Internet speed tests, just to name a few.

Further Readings and At)tributions:

Rumps on Github
py2app documentation
Icon by Freepik

Top comments (1)

Collapse
 
egigoka profile image
Egor Egorov

By some dark magic py2app got my dependencies from local environment. Also with import from another .py file. My shitcode on another my shitcode and it worked. Freaking awesome! :D