Daniel Romero

Weather CLI in Python

September 13, 2018

In this post we’ll go over the steps followed to develop a minimal weather CLI in Python, using the Dark Sky API.

Setup

First, we need to create a new directory for the project wherever it is that we place our projects:

$ mkdir ~/projects/weather_cli
$ cd ~/projects/weather_cli

Then we’ll start a Pipenv shell for this project and install the required dependencies:

$ pipenv shell
$ pipenv install requests
$ pipenv install click
$ pipenv install geopy

The Pipenv graph command should output something similar to this:

$ pipenv graph
click==6.7
geopy==1.16.0
  - geographiclib [required: >=1.49,<2, installed: 1.49]
requests==2.19.1
  - certifi [required: >=2017.4.17, installed: 2018.8.24]
  - chardet [required: >=3.0.2,<3.1.0, installed: 3.0.4]
  - idna [required: >=2.5,<2.8, installed: 2.7]
  - urllib3 [required: >=1.21.1,<1.24, installed: 1.23]

We then create the Python file where we’ll place our code:

$ touch weather_cli.py

Create an account at Dark Sky and grab your secret key from your dev account page and set it as an environment variable in your system, I have named it DARK_SKY_API_KEY, but you can name whatever you want.

Now that we have all we need to develop or weather CLI. Let’s start coding!

Code

We’ll create a search_weather function which we’ll call when executing the file. Next, we have to use the geopy library to get the coordinates for a given location, we’ll print the location with its coordinates if everything goes according to our plan, just to check everything went ok. If we can’t find the location introduced by the user we’ll exit with sys.exit(1) being the 1 the exit code which determines the exit status of the program when it finishes running (generally, 0 for success and 1 for error, note this it’s not unique of Python). It’s also necessary to specify a custom User-Agent for our geopy requests. The code with all these changes will look like this:

import sys

from geopy.geocoders import Nominatim


def search_weather_forecast():
    geolocator = Nominatim(user_agent="weather_cli")
    location = geolocator.geocode(input("Introduce your location: "))
    if location is None:
        print("We couldn't find the specified location")
        sys.exit(1)

    print(f"Location: {location}, "
          f"latitude: {location.latitude}, "
          f"longitude: {location.longitude}")


if __name__ == '__main__':
    search_weather_forecast()

Now, we can remove the prints that we put in place to check everything was ok, and we’re going to create a SEPARATOR variable that we’ll use to separate between sections in the output of our app. We also create a printheader and printfooter functions, we have to include attribution to the Dark Sky API as they specify in their docs, which we’ll do in our printfooter function. We read the DARKSKYAPIKEY from the environment variables, format the Dark Sky url with our api key and coordinates of the specified location. Next, we perform the request with the formatted url using the requests library, we check if the status code of the response is 200 (OK), if it is, we parse the response to json using the json library that ships with Python and finally we print a json.dump to check the response is what we expected:

import json
import os
import sys

import requests
from geopy.geocoders import Nominatim

SEPARATOR = "\n-------------------------------------------------------------\n"


def search_weather_forecast():
    geolocator = Nominatim(user_agent="weather_cli")
    location = geolocator.geocode(input("Introduce your location: "))
    if location is None:
        print("We couldn't find the specified location")
        sys.exit(1)

    print(f"Location: {location}, "
          f"latitude: {location.latitude}, "
          f"longitude: {location.longitude}")

    print_header_for_address(location.address)

    api_key = os.environ.get("DARK_SKY_API_KEY")
    url = f"https://api.darksky.net/forecast/{api_key}/{location.latitude}," \
          f"{location.longitude}?lang=es&units=si&exclude=hourly,flags"

    response = requests.get(url)

    if response.status_code == 200:
        parsed = json.loads(response.text)
        print(json.dumps(parsed, indent=4, sort_keys=True))

    print_footer()


def print_header_for_address(address):
    print(SEPARATOR)
    print(f"Searching weather forecast for {address}")
    print(SEPARATOR)


def print_footer():
    print("Powered by Dark Sky")
    print("https://darksky.net/poweredby/")


if __name__ == '__main__':
    search_weather_forecast()

When executing the code it should give us an output similar to this:

$ python weather_cli.py

Introduce your location: Berlin

-------------------------------------------------------------

Searching weather forecast for Berlin, 10117, Deutschland

-------------------------------------------------------------

{
    "currently": {
        "apparentTemperature": 14.86,
        "cloudCover": 0.79,
        "dewPoint": 11.52,
        "humidity": 0.8,
        "icon": "rain",
        "ozone": 277.58,
        "precipIntensity": 0.442,
        "precipProbability": 0.45,
        "precipType": "rain",
        "pressure": 1021.05,
        "summary": "Drizzle",
        "temperature": 14.86,
        "time": 1536836271,
        "uvIndex": 4,
        "visibility": 10.14,
        "windBearing": 55,
        "windGust": 4.27,
        "windSpeed": 2.79
    },
...

It’s working! Now, all that’s left to do is extract the relevant information that we want from the response and output it.

Since we want to keep things simple we’re going to extract the data for the current day only. We create two functions getdayweatherfromdata which receives the data dictionary for today’s weather and returns a NamedTuple that we call WeatherForecast with the attributes that we’re interested in, and print_forecast which receives our WeatherForecast NamedTuple and prints it, formatting the dates and times and calculating the UV damage risk given the UV index.

The final code should look like this:

import datetime
import json
import os
import sys
from collections import namedtuple

import requests
from geopy.geocoders import Nominatim
import click

WeatherForecast = namedtuple(
    "DayWeather",
    "summary, max_temperature, min_temperature,"
    "apparent_min_temperature, apparent_max_temperature,"
    "humidity, cloud_cover, precipitation_probability,"
    "precipitation_intensity, uv_index, uv_damage_risk,"
    "uv_index_time, wind_speed, sunrise_time, sunset_time",
)

SEPARATOR = "\n-------------------------------------------------------------\n"


@click.command()
def search_weather_forecast():
    geolocator = Nominatim(user_agent="weather_cli")
    print(SEPARATOR)
    user_location = input("Introduce your location: ")
    location = geolocator.geocode(user_location)
    if location is None:
        print("We couldn't find the specified location")
        sys.exit(1)

    print_header_for_address(location.address)

    api_key = os.environ.get("DARK_SKY_API_KEY")
    url = f"https://api.darksky.net/forecast/{api_key}/{location.latitude}," \
          f"{location.longitude}?lang=en&units=si&exclude=hourly,flags"

    response = requests.get(url)
    if response.status_code == 200:
        parsed = json.loads(response.text)

        today = parsed["daily"]["data"][0]
        today_forecast = get_weather_forecast_from_data(today)

        print_forecast(today_forecast)

    print_footer()


def print_header_for_address(address: str):
    print(SEPARATOR)
    print(f"Searching weather forecast for {address}")


def get_weather_forecast_from_data(data: dict) -> WeatherForecast:
    uv_index_time = format_timestamp(data["uvIndexTime"])
    sunrise_time = format_timestamp(data["sunriseTime"])
    sunset_time = format_timestamp(data["sunsetTime"])

    uv_damage_risk = calculate_uv_damage_risk(data["uvIndex"])

    weather_forecast = WeatherForecast(
        summary=data["summary"],
        max_temperature=data["temperatureMax"],
        min_temperature=data["temperatureMin"],
        apparent_min_temperature=data["apparentTemperatureMin"],
        apparent_max_temperature=data["apparentTemperatureMax"],
        humidity=data["humidity"],
        cloud_cover=data["cloudCover"],
        precipitation_probability=data["precipProbability"],
        precipitation_intensity=data["precipIntensity"],
        uv_index=data["uvIndex"],
        uv_damage_risk=uv_damage_risk,
        uv_index_time=uv_index_time,
        wind_speed=data["windSpeed"],
        sunrise_time=sunrise_time,
        sunset_time=sunset_time,
    )

    return weather_forecast


def format_timestamp(timestamp: float) -> str:
    dt = datetime.datetime.fromtimestamp(timestamp)
    str_dt = dt.strftime("%H:%M %d/%m/%Y")

    return str_dt


def calculate_uv_damage_risk(uv_index: int) -> str:
    if 0 <= uv_index < 3:
        return "Low"
    if 3 <= uv_index < 6:
        return "Moderate"
    if 6 <= uv_index < 8:
        return "High"
    if 8 <= uv_index < 11:
        return "Very high"
    if 11 <= uv_index:
        return "Extreme"


def print_forecast(forecast: WeatherForecast):
    print(SEPARATOR)
    print(
        f"Forecast for today\n\n"
        f"{forecast.summary}\n"
        f"Max temperature: {forecast.max_temperature}\n"
        f"Min temperature: {forecast.min_temperature}\n"
        f"Min apparent temperature: {forecast.apparent_min_temperature}°C\n"
        f"Max apparent temperature: {forecast.apparent_max_temperature}°C\n"
        f"Humidity: {int(forecast.humidity) * 100}%\n"
        f"Cloud cover percentage: {forecast.cloud_cover}%\n"
        f"Precipitation probability:  "
        f"{int(forecast.precipitation_probability) * 100}% "
        f"with an intensity of: {forecast.precipitation_intensity}mm/h\n"
        f"UV index: {forecast.uv_index}\n"
        f"Max UV index at: {forecast.uv_index_time}\n"
        f"Damage risk due to unprotected sun exposure: "
        f"{forecast.uv_damage_risk}\n"
        f"Wind speed: {forecast.wind_speed}m/s\n"
        f"Today the sun rises at: {forecast.sunrise_time}\n"
        f"Today the sun sets at: {forecast.sunset_time}"
    )
    print(SEPARATOR)


def print_footer():
    print("Powered by Dark Sky")
    print("https://darksky.net/poweredby/")
    print()


if __name__ == '__main__':
    search_weather_forecast()

In the end, the output of our utility should look something like the following:

$ python weather_cli.py
 
-------------------------------------------------------------

Introduce your location: Berlin

-------------------------------------------------------------

Searching weather forecast for Berlin, 10117, Deutschland

-------------------------------------------------------------


Forecast for today

Light rain in the morning.
Max temperature: 16.83
Min temperature: 13.44
Min apparent temperature: 13.44°C
Max apparent temperature: 16.83°C
Humidity: 0%
Cloud cover percentage: 0.79%
Precipitation probability:  0% with an intensity of: 0.1702mm/h
UV index: 4
Max UV index at: 13:00 13/09/2018
Damage risk due to unprotected sun exposure: Moderate
Wind speed: 2.36m/s
Today the sun rises at: 06:38 13/09/2018
Today the sun sets at: 19:28 13/09/2018

-------------------------------------------------------------

Powered by Dark Sky
https://darksky.net/poweredby/

Now for the final touch we’ll use the Click library to make our searchweatherforecast function into a CLI command.

First, we need to create a setup.py file. This will help us to use the python module we are writing as a command line tool. It is also the recommended way to write command line tools in Python.

from setuptools import setup


setup(
    name='weather_cli',
    version='0.1',
    py_modules=['weather_cli'],
    install_requires=[
        'Requests',
        'Geopy',
        'Click',
    ],
    entry_points='''
        [console_scripts]
        weather_cli=weather_cli:search_weather_forecast
    '''
)

Here you can see our tool entry point being our searchweatherforecast function in weather_cli.

We have to add the click.command() decorator just on top of our searchweatherforecast function like so:

...
@click.command()
def search_weather_forecast():
	...

Now we can test the script by installing it with:

$ pip install --editable .

Afterwards our command should be available:

$ weather_cli

-------------------------------------------------------------

Introduce your location: Berlin

-------------------------------------------------------------

Searching weather forecast for Berlin, 10117, Deutschland

-------------------------------------------------------------

Forecast for today

...

You can check out the final code