New to Qt5 and Python? Check out our complete PyQt5 tutorial.

screenshot-weather.jpg

Raindar, desktop Weather App

Pulling data from a remote API

Get up-to-date weather direct to your desktop, including meterological data and week-ahead predictions.

The OpenWeatherMap API

Requests to the API can take a few moments to complete. If we perform these in the main application loop this will cause our app to hang while waiting for data. To avoid this we perform all requests in seperate worker threads,

This worker collects both the current weather and a forecast, and returns this to the main thread to update the UI.

First we define a number of custom signals which the worker can emit. These include finished a generic signal for the worker completing, error which emits an Exception message should an error occur and result which returns the result of the API call. The data is returned as two separate dict objects, one representing the current weather and one for the forecast.

class WorkerSignals(QObject):
    '''
    Defines the signals available from a running worker thread.
    '''
    finished = pyqtSignal()
    error = pyqtSignal(str)
    result = pyqtSignal(dict, dict)

The WeatherWorker runnable handles the actual requests to the API. It is initialized with a single parameter location which gives the location that the worker will retrieve the weather data for. Each worker performs two requests, one for the weather, and one for the forecast, receiving a JSON strings from the OpenWeatherMap.org. These are then unpacked into dict objects and emitted using the .result signal.

class WeatherWorker(QRunnable):
    '''
    Worker thread for weather updates.
    '''
    signals = WorkerSignals()
    is_interrupted = False

    def __init__(self, location):
        super(WeatherWorker, self).__init__()
        self.location = location

    @pyqtSlot()
    def run(self):
        try:
            params = dict(
                q=self.location,
                appid=OPENWEATHERMAP_API_KEY
            )

            url = 'http://api.openweathermap.org/data/2.5/weather?%s&units=metric' % urlencode(params)
            r = requests.get(url)
            weather = json.loads(r.text)

            # Check if we had a failure (the forecast will fail in the same way).
            if weather['cod'] != 200:
                raise Exception(weather['message'])

            url = 'http://api.openweathermap.org/data/2.5/forecast?%s&units=metric' % urlencode(params)
            r = requests.get(url)
            forecast = json.loads(r.text)

            self.signals.result.emit(weather, forecast)

        except Exception as e:
            self.signals.error.emit(str(e))

        self.signals.finished.emit()

The User Interface

The Raindar UI was created using Qt Designer, and saved as .ui file, which is available for download. This was converted to an importable Python file using pyuic5.

With the main window layout defined in Qt Designer. To create the mainwindow we simply create a subclass of Ui_MainWindow (and QMainWindow) and call self.setupUi(self) as normal.

To trigger the request for weather data using the push button we connect it's pressed signal to our custom update_weather slot.

Finally we create our thread pool class, to handle running our workers and show the main window.

class MainWindow(QMainWindow, Ui_MainWindow):

    def __init__(self, *args, **kwargs):
        super(MainWindow, self).__init__(*args, **kwargs)
        self.setupUi(self)
        self.pushButton.pressed.connect(self.update_weather)
        self.threadpool = QThreadPool()
        self.show()

Requesting and Refreshing data

Pressing the button triggers the update_weather slot method. This creates a new WeatherWorker instance, passing in the currently set location from the lineEdit box. The result and error signals of the worker are connected up to the weather_result handler, and to our custom alert handler respectively.

The alert handler uses QMessageBox to display a message box window, containing the error from the worker.

    def update_weather(self):
        worker = WeatherWorker(self.lineEdit.text())
        worker.signals.result.connect(self.weather_result)
        worker.signals.error.connect(self.alert)
        self.threadpool.start(worker)

    def alert(self, message):
        alert = QMessageBox.warning(self, "Warning", message)

Handling the result

The weather and forecast dict objects returned by the workers are emitted through the result signal. This signal is connected to our custom slot weather_result, which receives the two dict objects. This method is responsible for updating the UI with the result returned, showing both the numeric data and updating the weather icons.

The weather results are updated to the UI by setText on the defined QLabels, formatted to decimal places where appropriate.

    def weather_result(self, weather, forecasts):
        self.latitudeLabel.setText("%.2f °" % weather['coord']['lat'])
        self.longitudeLabel.setText("%.2f °" % weather['coord']['lon'])

        self.windLabel.setText("%.2f m/s" % weather['wind']['speed'])

        self.temperatureLabel.setText("%.1f °C" % weather['main']['temp'])
        self.pressureLabel.setText("%d" % weather['main']['pressure'])
        self.humidityLabel.setText("%d" % weather['main']['humidity'])

        self.weatherLabel.setText("%s (%s)" % (
            weather['weather'][0]['main'],
            weather['weather'][0]['description']
        )

The timestamps are processed using a custom from_ts_to_time_of_day function to return a user-friendlier time of day in am/pm format with no leading zero.

        def from_ts_to_time_of_day(ts):
            dt = datetime.fromtimestamp(ts)
            return dt.strftime("%I%p").lstrip("0")

        self.sunriseLabel.setText(from_ts_to_time_of_day(weather['sys']['sunrise']))

The OpenWeatherMap.org has a custom mapping for icons, with each weather state indicated by a specific number — the full mapping is available here. We're using the free fugue icon set, which has a pretty complete set of weather-related icons. To simplify the mapping between the OpenWeatherMap.org and the icon set, the icons have been renamed to their respective OpenWeatherMap.org numeric code.

        def set_weather_icon(self, label, weather):
            label.setPixmap(
                QPixmap(
                    os.path.join('images', "%s.png" % weather[0]['icon'])
                        )
            )

First we set the current weather icon, from the weather dict, then iterate over the first 5 of the provided forecasts. The forecast icons, times and temperature labels were defined in Qt Designer with the names forecastIcon<n>, forecastTime<n> and forecastTemp<n>, making it simple to iterate over them in turn and retrieve them using getattr with the current iteration index.

        self.set_weather_icon(self.weatherIcon, weather['weather'])

        for n, forecast in enumerate(forecasts['list'][:5], 1):
            getattr(self, 'forecastTime%d' % n).setText(from_ts_to_time_of_day(forecast['dt']))
            self.set_weather_icon(getattr(self, 'forecastIcon%d' % n), forecast['weather'])
            getattr(self, 'forecastTemp%d' % n).setText("%.1f °C" % forecast['main']['temp'])

The full source is available on Github.

Challenges

A few simple ways you could extend this application —

  1. Eliminate repeated requests for the data, by using request_cache. This will persist the request data between runs.
  2. Support for multiple locations.
  3. Configurable forecast length.
  4. Make the current weather available on a toolbar icon while running.

Enjoyed this?
Then you will enjoy my book!

The hands-on guide to making desktop apps with Python. Build real apps — learn more than just the basics. Updated 2019.

Check it out

Discussion