Creating a Python Weather App for Terminal
Published 12 months ago on July 3, 2023

Back when I first got a Google Home Mini, I was spending way too much time in terminal and I slowly started replacing commands I’d use on the Google device with commands on my MacBook, because, as always, why not?

The title says it all. I can now run a weather command in terminal to grab the weather of my home town, including wind speed / direction, temperature and general description (clouds, rain, snow, sun). It’s a pretty crude program and it’ll probably crash if you specify an unknown location, but thats okay. Don’t specify an unknown location and it’ll be fine!

This was the perfect excuse to brush up on a bit of python at the time. Although I threw this code together quickly, the functionality I sought is there, and the real reason behind it is because I’ve been neglecting Python for too long. All the years I’ve been writing code, I’ve barely touched this language. Because I started with PHP and then C++, I am very comfortable with a C-style syntax, so when indentation and colons become the norm and semi-colons don’t exist, its very foreign to me. So, the more I write, the more comfortable I get.

Use these links to quickly jump to the sections below

Defining Some Essential Parameters
Making the Request
Parsing the Data
Printing the Data
The Full Code
The Full Code Updated


First of all, I needed to find a free weather API provider. Easy stuff, thanks Google. OpenWeather provide just that. Of course there are limited API requests, but I don’t think I will be exceeding those any time soon. From there on, it’s just as easy as sending a GET request to fetch some JSON and then parsing it to be displayed how I see fit.

The only technicality involved was converting a wind direction from degrees to an actual direction. But a spot of math and a big List later, simple.

I had to make an account on https://openweathermap.org which is so self-explanatory I won’t even entertain explaining it. It takes a few hours for your API key to become active though, I kind of forgot until the evening so I don’t know exactly how long it takes.

Once thats done, take a look at their API documentation to see what kind of requests you can make. I just went with the first one, get current data by city name.

What the documentation doesn’t say is that you need to include your API key as a parameter with the name appid

So let’s get to writing some python code!

Defining Some Essential API Parameters

We need our API endpoint, API key and some additional parameters to tell the API which units we want, and the location we want to query for. That’s what we are defining here. We declare and initialise these variables after our imports. We also define our direction_list here which will help us figure out which direction the wind is blowing, because the API returns a wind direction in meteorological degrees.

# api-endpoint URL = "https://api.openweathermap.org/data/2.5/weather" location = "london" api_key = "API_KEY" units = "metric" temp_unit = "celsius" PARAMS = {'q': location, 'appid': api_key, 'units': units} direction_list = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW", "N"]

Making the Request

Next, we make the request to the OpenWeather API using the python requests library, and pass our params along with the request. It’s a good idea to see what data the request returns before deciding how you want to format the data for output, so we’ll do that here too. Note that this won’t be in the final script, however. Also, not all data fields are guaranteed.

req = requests.get(url=URL, params=PARAMS) print(req.json())

This will return some output that looks something like this:

{ 'coord': { 'lon': -0.1257, 'lat': 51.5085 }, 'weather': [ { 'id': 500, 'main': 'Rain', 'description': 'light rain', 'icon': '10n' } ], 'base': 'stations', 'main': { 'temp': 17.56, 'feels_like': 17.7, 'temp_min': 16.49, 'temp_max': 18.23, 'pressure': 1005, 'humidity': 89 }, 'visibility': 10000, 'wind': { 'speed': 5.66, 'deg': 240 }, 'rain': { '1h': 0.15 }, 'clouds': { 'all': 100 }, 'dt': 1688161574, 'sys': { 'type': 2, 'id': 2075535, 'country': 'GB', 'sunrise': 1688096796, 'sunset': 1688156478 }, 'timezone': 3600, 'id': 2643743, 'name': 'London', 'cod': 200 }

Assuming everything worked well, you should have some similar output. We’re mainly interested in the weather array, main object and the wind object.

The next step in our code is to define a convenience function with the sole purpose of returning false if a key doesn’t exist, and true otherwise. We’ll use it in every variable declaration that relies on fetching information from the JSON, to ensure the program doesn’t crash in the event of a particular piece of data missing. There are also several if-statements that use this function to ensure the core data is present in the JSON data and the program will terminate if it is missing. The ijkp(json, key) function simply tries to assign the value of a particular key to another variable and handles a KeyError if the key doesn’t exist.

# is json key present def ijkp(json, key): try: buf = json[key] except KeyError: return False return True if not ijkp(data, "main"): print("Critical data missing(main). Terminating gracefully") sys.exit() if not ijkp(data, "weather"): print("Critical data missing(weather). Terminating gracefully") sys.exit() if not ijkp(data, "wind"): print("Critical data missing(wind). Terminating gracefully") sys.exit()

Parsing the Data

Finally, its time to parse the data into a bunch of variables to prepare for output. There are a couple of particulars to note with the following code. Each variable gets a default value of None if the relevant key doesn’t exist. The temp variable is the exception, because the formatting used in the output expects a floating point number, we set the value to an unrealistic number in the event that the JSON key doesn’t exist.

temp = float(data['main']['temp']) if ijkp(data['main'], "temp") else float(-9999) feels_like = int(data['main']['feels_like']) if ijkp(data['main'], "feels_like") else None temp_low = int(data['main']['temp_min']) if ijkp(data['main'], "temp_min") else None temp_high = int(data['main']['temp_max']) if ijkp(data['main'], "temp_max") else None humidity = int(data['main']['humidity']) if ijkp(data['main'], "humidity") else None weather_main = data['weather'][0]['main'] if ijkp(data['weather'][0], "main") else None weather_desc = data['weather'][0]['description'] if ijkp(data['weather'][0], "description") else None wind_speed = data['wind']['speed'] if ijkp(data['wind'], "speed") else None wind_speed_mph = int(wind_speed * 2.237) dir_index = int(int(data['wind']['deg']) / 22.5) if ijkp(data['wind'], "deg") else None wind_dir = direction_list[dir_index]

The wind speed is converted to MPH with a magic number. Bad practice, it’s better to declare a constant at the top of the code somewhere but I’m not changing it, as stubborn as I am. Personal projects, right? 😅

The wind direct is calculated using a bit of shady math. We have 16 possible wind directions ranging from North and North-East, all the way to West and North-West. The direction_list defined previously has 17 elements in it, 0-16, because both 0 degrees and 360 degrees is North.

Because the wind direction is given in meteorological degrees ranging from 1-360, we take the maximum number and divide that by the number of possible wind directions: 360/16 = 22.5.

This essentially means that every 22.5 degrees is a new wind direction, and so we divide the given wind direction value by 22.5 to map the wind direction to our direction_list values by taking the value of that division and using it as an index to the direction_list. To make sure we don’t end up with a non-integer number that can’t be used as an index, we cast the result of this calculation to an integer value.

For example, a Northerly wind could be either 0 or 360 degrees (I assume the API would only return 360, but I’m not actually sure).

0 / 22.5 = 0 (the first element of our list, North) 360 / 22.5 = 16 (the last element of our list, North) 180 / 22.5 = 8 (the element storing S, South) 352 / 22.5 = 15.6~, cast to an integer becomes 15 (NNW, North-North-West)

It’s not perfect, we could definitely improve it by rounding to the nearest whole number, but I quite liked the elegance in complexity of this solution and so I never changed it.

Printing The Data

With all that data parsing taken care of, it’s time to print our API request response to the user.

print("") print("*" * 40) print("Weather for %s" % location.capitalize()) print("Current time: %s" % datetime.datetime.now().strftime("%H:%M:%S")) print("*" * 40) print("") print("Weather: %s" % weather_desc) print("Temperature: %d degrees %s" % (temp, temp_unit)) print("Feels like: %s degrees %s" % (feels_like, temp_unit)) print("Wind: %imph in a %s direction" % (wind_speed_mph, wind_dir)) print("Humidity: %i%%" % humidity) print("") print("*" * 40) print("")

We use the old style of python string formatting here to display the information that I wanted to see specifically. The location, time, brief weather description, temperature and feels-like temperature, wind and humidity. Of course, you could choose to display more or less information. That’s the beauty of programming, you can do the same thing many ways and make changes so it suits your own needs!


The Full Code

#! /usr/local/bin/python import json import requests import sys import datetime # api-endpoint URL = "https://api.openweathermap.org/data/2.5/weather" location = "london" api_key = "API_KEY" units = "metric" temp_unit = "celsius" PARAMS = {'q': location, 'appid': api_key, 'units': units} direction_list = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW", "N"] req = requests.get(url=URL, params=PARAMS) data = req.json() # is json key present def ijkp(json, key): try: buf = json[key] except KeyError: return False return True if not ijkp(data, "main"): print("Critical data missing(main). Terminating gracefully") sys.exit() if not ijkp(data, "weather"): print("Critical data missing(weather). Terminating gracefully") sys.exit() if not ijkp(data, "wind"): print("Critical data missing(wind). Terminating gracefully") sys.exit() temp = float(data['main']['temp']) if ijkp(data['main'], "temp") else float(-9999) feels_like = int(data['main']['feels_like']) if ijkp(data['main'], "feels_like") else None temp_low = int(data['main']['temp_min']) if ijkp(data['main'], "temp_min") else None temp_high = int(data['main']['temp_max']) if ijkp(data['main'], "temp_max") else None humidity = int(data['main']['humidity']) if ijkp(data['main'], "humidity") else None weather_main = data['weather'][0]['main'] if ijkp(data['weather'][0], "main") else None weather_desc = data['weather'][0]['description'] if ijkp(data['weather'][0], "description") else None wind_speed = data['wind']['speed'] if ijkp(data['wind'], "speed") else None wind_speed_mph = int(wind_speed * 2.237) dir_index = int(int(data['wind']['deg']) / 22.5) if ijkp(data['wind'], "deg") else None wind_dir = direction_list[dir_index] print("") print("*" * 40) print("Weather for %s" % location.capitalize()) print("Current time: %s" % datetime.datetime.now().strftime("%H:%M:%S")) print("*" * 40) print("") print("Weather: %s" % weather_desc) print("Temperature: %d degrees %s" % (temp, temp_unit)) print("Feels like: %s degrees %s" % (feels_like, temp_unit)) print("Wind: %imph in a %s direction" % (wind_speed_mph, wind_dir)) print("Humidity: %i%%" % humidity) print("") print("*" * 40) print("")

Finally, as I often do, I moved this script to my /scripts/ directory, removed its extension and made sure it is permitted to execute, so I can run it from anywhere on my system, within terminal. Note the shebang at the start of the code, so bash knows what to execute this script with.

weather.py-script-output


The Full Code Updated

While I was writing this post I was looking at the code and thinking “I could make a few good changes here”, so here’s a more updated version of the code. OpenWeather have since upgraded their API to version 3.0 but I’ll be sticking with the 2.5 API because updating code is one thing but switching to a new API version is a whole different world of pain. I’ve removed some unused variables, moved everything to functions and added argparse so we can pass a location to the script, but use London if no location is given. I also added rounding to the wind direction calculations as mentioned earlier!

#! /usr/local/bin/python import requests import sys import datetime import argparse # api-endpoint URL = "https://api.openweathermap.org/data/2.5/weather" location = "london" api_key = "API_KEY" units = "metric" temp_unit = "celsius" KMH_MPH_MX = 2.237 PARAMS = {'q': location, 'appid': api_key, 'units': units} direction_list = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW", "N"] # is json key present def ijkp(json, key): try: buf = json[key] except KeyError: return False return True def prepare_and_output_data(data): temp = float(data['main']['temp']) if ijkp(data['main'], "temp") else float(-9999) feels_like = int(data['main']['feels_like']) if ijkp(data['main'], "feels_like") else None humidity = int(data['main']['humidity']) if ijkp(data['main'], "humidity") else None weather_desc = data['weather'][0]['description'] if ijkp(data['weather'][0], "description") else None wind_speed = data['wind']['speed'] if ijkp(data['wind'], "speed") else None wind_speed_mph = int(wind_speed * KMH_MPH_MX) dir_index = round(int(data['wind']['deg']) / 22.5) if ijkp(data['wind'], "deg") else None wind_dir = direction_list[dir_index] print("") print("*" * 40) print(f"Weather for {location.capitalize()}") print(f"Current time: {datetime.datetime.now().strftime('%H:%M:%S')}") print("*" * 40) print(f"Weather: {weather_desc}") print(f"Temperature: {temp} degrees {temp_unit}") print(f"Feels like: {feels_like} degrees {temp_unit}") print(f"Wind: {wind_speed_mph}mph in a {wind_dir} direction") print(f"Humidity: {humidity}%") print("*" * 40) print("") def parse_args(): parser = argparse.ArgumentParser(description="Python weather") parser.add_argument("-l", "--location", help="Provide a location") return parser.parse_args() def get_data(): req = requests.get(url=URL, params=PARAMS) data = req.json() if not ijkp(data, "main") or not ijkp(data, "weather") or not ijkp(data, "wind"): print("Critical data missing(weather). Terminating gracefully") sys.exit() return data if __name__ == '__main__': args = parse_args() if args.location is not None: location = args.location data = get_data() prepare_and_output_data(data)