Goodforbitcoin, a Cryptocurrency market tracker
Track cryptocurrency market values and trade volumes

Goodforbitcoin is a simple cryptocurrency market-tracker. It displays daily market rates, including high, low and close valuations, alongside market trade volume for a range of popular cryptocurrencies. It comes with built-in support for BTC, ETH, LTC, EOS, XRP and BCH currencies, with EUR, USD and GBP as base currencies for valuations.

The only Bitcoin I own I was given by some random chap on the internet. I am by no means knowledgeable about cryptocurrencies, this app is just for fun.

Read on for an overview of how the application is put together, including interacting with APIs from PyQt5, plotting data with PyQtGraph and packaging apps with PyInstaller.

The app is powered by the CryptoCompare.com API from which we retrieve per-day high, low, open and close values, alongside market trading volume amounts. The resulting exchange rates are plotted using PyQtGraph along with a currency exchange list-view which is updated as you move your mouse through the plotted timeline. The bundled app is available for Windows and Mac.

Working with the API

The first thing we need is a data source. Here we're using CryptoCompare.com which offers free developer API access for non-commercial purposes, including historic data.

The API calls

We're using two separate API calls to plot our graphs — 

  1. The daily historic exchange values for all supported cryptocurrencies (BTC, ETH, LTC, EOS, XRP and BCH) against a set of base-currencies (EUR, USD and GBP).
  2. The daily market volume data, giving the amount of trades occurring.

The two API calls we are doing are...

python
https://min-api.cryptocompare.com/data/histoday?fsym={fsym}&tsym={tsym}&limit={limit}
https://min-api.cryptocompare.com/data/exchange/histoday?tsym={tsym}&limit={limit}

In the URLs fsym is the from symbol the currency converting from, tsym is to symbol the currency we're converting to, and limit which is the number of results to return on the request — since we're calling /histoday this is the number of days data to return.

The requests are performed with requests, passing a per-application key in an authentication Apikey header, e.g.

python
auth_header = {
    'Apikey': CRYPTOCOMPARE_API_KEY
}

url = 'https://min-api.cryptocompare.com/data/exchange/histoday?tsym={tsym}&limit={limit}'
r = requests.get(
    url.format(**{
        'tsym': self.base_currency,
        'limit': NUMBER_OF_TIMEPOINTS-1,
        'extraParams': 'www.mfitzp.com',
        'format': 'json',
    }),
    headers=auth_header,
)

Performing API requests in threads

Requests to APIs take time to complete. If we make the request directly in the GUI thread it will block the rest of the application executing — including responding to user input. The application would become unresponsive (spinning wheel of death, faded window).

We can avoid this problem quite easily by performing the API requests in a separate thread.

For a complete overview this QWorker approach see the PyQt5 threads tutorial.

First we define a signals QObject which contains the signals we want to emit from our worker thread. This includes signals to emit finished, error, progress (how much is complete) and data (the returned data).

python
class WorkerSignals(QObject):
    """
    Defines the signals available from a running worker thread.
    """
    finished = pyqtSignal()
    error = pyqtSignal(tuple)
    progress = pyqtSignal(int)
    data = pyqtSignal(dict, list)
    cancel = pyqtSignal()

We also add a cancel signal which allows the parent app to signal to an active worker thread when a new request has been queued before the first one completes. This signal sets a flag is_interrupted on the worker, which is checked before each currency's data is downloaded. If True it will return without emitting the finished signal.

python
class UpdateWorker(QRunnable):
    """
    Worker thread for updating currency.
    """
    signals = WorkerSignals()

    def __init__(self, base_currency):
        super(UpdateWorker, self).__init__()
        self.is_interrupted = False
        self.base_currency = base_currency
        self.signals.cancel.connect(self.cancel)

    @pyqtSlot()
    def run(self):
        auth_header = {
            'Apikey': CRYPTOCOMPARE_API_KEY
        }
        try:
            rates = {}
            for n, crypto in enumerate(AVAILABLE_CRYPTO_CURRENCIES, 1):
                url = 'https://min-api.cryptocompare.com/data/histoday?fsym={fsym}&tsym={tsym}&limit={limit}'
                r = requests.get(
                    url.format(**{
                        'fsym': crypto,
                        'tsym': self.base_currency,
                        'limit': NUMBER_OF_TIMEPOINTS-1,
                        'extraParams': 'www.mfitzp.com',
                        'format': 'json',
                    }),
                    headers=auth_header,
                )
                r.raise_for_status()
                rates[crypto] = r.json().get('Data')

                self.signals.progress.emit(int(100 * n / len(AVAILABLE_CRYPTO_CURRENCIES)))

                if self.is_interrupted:
                    # Stop without emitting finish signals.
                    return

            url = 'https://min-api.cryptocompare.com/data/exchange/histoday?tsym={tsym}&limit={limit}'
            r = requests.get(
                url.format(**{
                    'tsym': self.base_currency,
                    'limit': NUMBER_OF_TIMEPOINTS-1,
                    'extraParams': 'www.mfitzp.com',
                    'format': 'json',
                }),
                headers=auth_header,
            )
            r.raise_for_status()
            volume = [d['volume'] for d in r.json().get('Data')]

        except Exception as e:
            self.signals.error.emit((e, traceback.format_exc()))
            return

        self.signals.data.emit(rates, volume)
        self.signals.finished.emit()

    def cancel(self):
        self.is_interrupted = True

A separate API reqeust is performed for each cryptocurrency, updating the progress bar (emitting (int(100 * n / len(AVAILABLE_CRYPTO_CURRENCIES))) on each iteration) and then a final request is made to retrieve the volume information. Once all requests are finished the resulting data is emitted using the earlier defined signals.

Caching

The free API comes with a generous limit of 100,000 calls/month, which you're unlikely to hit. However, it's still polite not to waste other people's bandwidth if you can avoid it. Since we're retrieving daily rates, there isn't any reason to download >1 time per day.

As we're performing the API calls using the requests library we can use requests_cache to automatically cache all our API requests transparently. This uses a simple SQLite file database to store the results of previous requests.

python
import requests_cache
requests_cache.install_cache(os.path.expanduser('~/.goodforbitcoin'))

With the cache enabled API responses will be cached and subsequent requests to the same URL will fetch from the cache (until it expires, set to 1 day by the API).

You can put the cache wherever you want on disk, the only requirement is that it is user-writeable (so it continues to work after the app is packaged).

Plotting the data

The API calls return high, low, open and close values for each day and for each cryptocurrency in addition to a separate market volume value. These are plotted as a series of lines, with each cryptocurrency close value plotted in a different colour, with high and low values drawed as dotted lines either side. The open value is not plotted.

PyQtGraph plot with multiple currencies and volume data PyQtGraph plot with multiple currencies and volume data

The currency axis

The currency values are all plotted on the same scale, using the same axis. We only plot the currency lines only once we have the data back from the API (in case any currencies are not activated) so the initial setup is just of the axis and grid. We also set the axis names, and add an infinite vertical line, which is just to track through the plot to get per-day currency conversion rates.

python
self.ax = pg.PlotWidget()
self.ax.showGrid(True, True)

self.line = pg.InfiniteLine(
    pos=-20,
    pen=pg.mkPen('k', width=3),
    movable=False  # We have our own code to handle dragless moving.
)

self.ax.addItem(self.line)
self.ax.setLabel('left', text='Rate')
self.p1 = self.ax.getPlotItem()
self.p1.scene().sigMouseMoved.connect(self.mouse_move_handler)

The axis' mouse move signal is connected to the custom mouse_move_handler slot, which moves the infinite line and updates the current rates shown in the rates table (see later).

The volume axis

The volume axis is plotted on a separate scale, as a dotted black line. This can be zoomed vertically independently of the currencies. This is a bit tricky to achieve in PyQtGraph, requiring you to manually create a ViewBox object and connect it up to the main axis.

python
# Add the right-hand axis for the market activity.
self.p2 = pg.ViewBox()
self.p2.enableAutoRange(axis=pg.ViewBox.XYAxes, enable=True)
self.p1.showAxis('right')
self.p1.scene().addItem(self.p2)
self.p2.setXLink(self.p1)
self.ax2 = self.p1.getAxis('right')
self.ax2.linkToView(self.p2)
self.ax2.setGrid(False)
self.ax2.setLabel(text='Volume')

Unlike for the currencies we do add the curve here, since we know it will always be present. The initial state is a diagonal line (for no reason).

python
self._market_activity = pg.PlotCurveItem(
    np.arange(NUMBER_OF_TIMEPOINTS), np.arange(NUMBER_OF_TIMEPOINTS),
    pen=pg.mkPen('k', style=Qt.DashLine, width=1)
)
self.p2.addItem(self._market_activity)

# Automatically rescale our twinned Y axis.
self.p1.vb.sigResized.connect(self.update_plot_scale)

We need connect the resized signal from the primary axis to our custom update_plot_scale slot, to automatically update the secondary axis dimensions.

python
def update_plot_scale(self):
    self.p2.setGeometry(self.p1.vb.sceneBoundingRect())

Now the two axes are defined, we can draw our plot lines.

Updating the plot

The plot is updated in response to the data being returned by the API request worker. This triggers the .redraw() method, which uses the data (available on self.data) to either add, or update lines to the plot.

python
    def redraw(self):
        y_min, y_max = sys.maxsize, 0
        x = np.arange(NUMBER_OF_TIMEPOINTS)

        # Pre-process data into lists of x, y values.
        for currency, data in self.data.items():
            if data:
                _, close, high, low = zip(*[
                    (v['time'], v['close'], v['high'], v['low'])
                    for v in data
                ])

                if currency in self._data_visible:
                    # This line should be visible, if it's not drawn draw it.
                    if currency not in self._data_lines:
                        self._data_lines[currency] = {}
                        self._data_lines[currency]['high'] = self.ax.plot(
                            x, high,  # Unpack a list of tuples into two lists, passed as individual args.
                            pen=pg.mkPen(self.get_currency_color(currency), width=2, style=Qt.DotLine)
                        )
                    else:
                        self._data_lines[currency]['high'].setData(x, high)

                    y_min, y_max = min(y_min, *low), max(y_max, *high)

                else:
                    # This line should not be visible, if it is delete it.
                    if currency in self._data_lines:
                        self._data_lines[currency]['high'].clear()
    ...

References to plotted lines are kept in a dictionary self._data_lines keyed by the cryptocurrency identifier. This allows us to check on each update whether we already have a line defined, and update it rather than recreating it. We can also remove lines for currencies that we no longer want to draw (if they've been deselected in the currency list).

The market activity (volume) plot however is always there, so we can just perform a simple update to the existing line.

python
self._market_activity.setData(x, self.volume)

In addition to the plotted lines, we also show a list with the currency conversion rates for all cryptocurrencies at the currently position in the graph. As you move your pointer back and forward these rates update automatically.

Create GUI Applications with Python & Qt6
The easy way to create desktop applications

My complete guide, updated for 2021 & PyQt6. Everything you need build real apps.

Downloadable ebook (PDF, ePub) & Complete Source code

To support developers in [[ countryRegion ]] I give a [[ localizedDiscount[couponCode] ]]% discount with the code [[ couponCode ]] — Enjoy!

For [[ activeDiscount.description ]] I'm giving a [[ activeDiscount.discount ]]% discount with the code [[ couponCode ]] — Enjoy!

Rates table

The rates table is a QTableView widget, using the Qt5 ModelView architecture.

Goodforbitcoin rates table Goodforbitcoin rates table

We define a QStandardItemModel model which we can use to update the data in the table, and set the headers for the colums. Finally, the .itemChanged signal is connect to our custom slot method check_check_state.

python
self.listView = QTableView()
self.model = QStandardItemModel()
self.model.setHorizontalHeaderLabels(["Currency", "Rate"])
self.model.itemChanged.connect(self.check_check_state)

If the item is checked, and the currency is not currently displayed (the currency identifier is not in our ._data_visible map) we add the currency to it and trigger a redraw. Likewise, if the item is unchecked but the currency is displayed, we remove it and trigger a redraw.

python
def check_check_state(self, i):
    if not i.isCheckable():  # Skip data columns.
        return

    currency = i.text()
    checked = i.checkState() == Qt.Checked

    if currency in self._data_visible:
        if not checked:
            self._data_visible.remove(currency)
            self.redraw()
    else:
        if checked:
            self._data_visible.append(currency)
            self.redraw()

We always download data for all currencies, even if they are not currently displayed, so we can update the plot immediately. You might want to have a go at changing this behaviour.

To automatically update the values on the rates table we have already connected our mouse_move_handler slot to mouse moves on the main axis. This slot receives a pos value which is a QPoint position relative to the axis. We first use the .x() value to set the position of the vertical line, and then hand off the int of the value to our update_data_viewer method.

python
def mouse_move_handler(self, pos):
    pos = self.ax.getViewBox().mapSceneToView(pos)
    self.line.setPos(pos.x())
    self.update_data_viewer(int(pos.x()))

This next method checks if the position i is within the range of our data (the number of days of data we have). Then for each currency it gets the corresponding value (the close value) and then sets this onto the second QStandardItems — the column with the currency exchange rates — as a 4dp number.

python
def update_data_viewer(self, i):
    if i not in range(NUMBER_OF_TIMEPOINTS):
        return

    for currency, data in self.data.items():
        self.update_data_row(currency, data[i])

def update_data_row(self, currency, data):
    citem, vitem = self.get_or_create_data_row(currency)
    vitem.setText("%.4f" % data['close'])

The get_or_create_data_row looks to see if a data row exists in the model for the corresponding currency. If it does it returns the existing row, if not it creates a new row by calling add_data_row. This means we don't need to define the rows explicitly, they are created automatically based on the data returned by the API.

python
def get_or_create_data_row(self, currency):
    if currency not in self._data_items:
        self._data_items[currency] = self.add_data_row(currency)
    return self._data_items[currency]

def add_data_row(self, currency):
    citem = QStandardItem()
    citem.setText(currency)
    citem.setForeground(QBrush(QColor(
        self.get_currency_color(currency)
    )))
    citem.setColumnCount(2)
    citem.setCheckable(True)
    if currency in DEFAULT_DISPLAY_CURRENCIES:
        citem.setCheckState(Qt.Checked)

    vitem = QStandardItem()

    vitem.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
    self.model.setColumnCount(2)
    self.model.appendRow([citem, vitem])
    self.model.sort(0)
    return citem, vitem

Packaging

Now we have the working app, the last step is to bundle it up for distribution. Installers for Windows and Mac are available at the top of this article. To do this we're using PyInstaller the current standard for bundling Python applications.

Because this app has no external data files building an installer for it is pretty straightforward. Install PyInstaller with pip3 install pyinstaller then run —

bash
pyinstaller crypto.py

This produces a spec file which contains the information needed by PyInstaller to build the distribution installers. This file is cross-platform and should be included in your source control so any improvements are persisted.

The automatically generated file is enough to package this application as-is, but we need a few tweaks to make it complete.

MacOS Retina Support

By default MacOS applications don't support retina (high resolution) screens. To enable this support you need to set the NSHighResolutionCapable flag in the application .plist bundled inside the .app. This is simple enough to do in PyInstaller.

Edit the .spec file to add the info_plist block shown below, with NSHighResolutionCapable

python
app = BUNDLE(coll,
            ...
             info_plist={
                 'NSHighResolutionCapable': 'True'
             },

Now, whenever you bundle your application, this flag will be added to the MacOS bundle .plist automatically.

Icons

To make the application show a custom icon while running we need to generate Windows .ico and MacOS .icns files and add these to the .spec definition.

A MacOSX icon bundle icon.icns contains multiple alternative icon sizes, which are laborious to generate by hand. The following script will take a single .png file input and automatically generate an .icns bundle containing the different sizes.

bash
#!/bin/bash

mkdir $1.iconset
sips -z 16 16     $1 --out $1.iconset/icon_16x16.png
sips -z 32 32     $1 --out $1.iconset/icon_16x16@2x.png
sips -z 32 32     $1 --out $1.iconset/icon_32x32.png
sips -z 64 64     $1 --out $1.iconset/icon_32x32@2x.png
sips -z 128 128   $1 --out $1.iconset/icon_128x128.png
sips -z 256 256   $1 --out $1.iconset/icon_128x128@2x.png
sips -z 256 256   $1 --out $1.iconset/icon_256x256.png
sips -z 512 512   $1 --out $1.iconset/icon_256x256@2x.png
sips -z 512 512   $1 --out $1.iconset/icon_512x512.png
cp $1 $1.iconset/icon_512x512@2x.png
iconutil -c icns $1.iconset
rm -R $1.iconset

This file saved as makeicns.sh and chmod +x makeicns.sh can then be used to generate an .icns bundle from a single large PNG, as follows.

bash
./makeicns.sh bitcoin-icon.png

You may want to check the resized icons and edit the lower resolution ones to simplify them to improve clarity. Just remove the rm -R $1.iconset step from the script.

For Windows we can generate an .ico file by loading a PNG into Gimp and resize down to 64x64, 32x32 and 16x16 on separate layers. Unlike for MacOS you can provide a single square image if you are happy to let it be resized automatically, just ensure it is saved as .ico.

The completed spec file

To complete the spec file we can manually set the name of the application (to Goodforbitcoin) and update the filenames for the bundled applications to match. In addition the PyInstaller script will have added a pathex variable with a static path.

python
pathex=['/Users/martin/repos/minute-apps/crypto'],

The pathex will be different if you generate this file yourself.

This can be removed if the .spec file is in the same folder are your applications base Python file to make the file portable. The last step is to add a number of hidden imports (modules which are not correctly detected by PyInstaller). These are only necessary for the Windows builds.

The completed spec file used to bundle the downloads is shown below.

python
# -*- mode: python ; coding: utf-8 -*-

block_cipher = None


a = Analysis(['crypto.py'],
             binaries=[],
             datas=[],
             hiddenimports=[
             'numpy.random.common',
             'numpy.random.bounded_integers',
             'numpy.random.entropy',
             ],
             hookspath=[],
             runtime_hooks=[],
             excludes=[],
             win_no_prefer_redirects=False,
             win_private_assemblies=False,
             cipher=block_cipher,
             noarchive=False)
pyz = PYZ(a.pure, a.zipped_data,
             cipher=block_cipher)
exe = EXE(pyz,
          a.scripts,
          [],
          exclude_binaries=True,
          name='Goodforbitcoin',
          debug=False,
          bootloader_ignore_signals=False,
          strip=False,
          upx=True,
          console=False,
          icon='resources/icon.ico'
          )
coll = COLLECT(exe,
               a.binaries,
               a.zipfiles,
               a.datas,
               strip=False,
               upx=True,
               upx_exclude=[],
               name='Goodforbitcoin')
app = BUNDLE(coll,
             name='Goodforbitcoin.app',
             icon='resources/icon.icns',
             bundle_identifier='com.learnpyqt.Goodforbitcoin',
             info_plist={
                 'NSHighResolutionCapable': 'True'
             },
)

The packaged Goodforbitcoin apps, along with the source code, can be downloaded using the links below.

Continue reading

Creating your first app with PyQt5  pyqt

In this tutorial we'll learn how to use PyQt to create desktop applications with Python. First we'll create a series of simple windows on your desktop to ensure that PyQt is working and introduce some of the basic concepts. Then we'll take a brief look at the event loop and how it relates to GUI programming in Python. Finally we'll look at Qt's QMainWindow which offers some useful common interface elements such as toolbars and menus. These will be explored in more detail in the subsequent tutorials. Creating an application Let's create our first application! To start create a new Python file — you can call it whatever you like (e.g. app.py) and save it somewhere accessible. We'll write our simple app in this file. We'll be editing within this file as we go along, and you may want to come back to earlier versions of your code, so remember to keep regular backups. The source code for the application is shown below. Type it in verbatim, and be careful not to make mistakes. If you do mess up, Python will let you know what's wrong. python from PyQt5.QtWidgets import QApplication, QWidget # Only needed for access to command line arguments import sys # You need one (and only one) QApplication instance per application. # Pass in sys.argv to allow command line arguments for your app. # If you know you won't use command line arguments QApplication([]) works too. app = QApplication(sys.argv) # Create a Qt widget, which will be our window. window = QWidget() window.show() # IMPORTANT!!!!! Windows are hidden by default. # Start the event loop. app.exec() # Your application won't reach here until you exit and the event # loop has stopped. First, launch your application. You can run it from the command line like any other Python script, for example -- bash python3 app.py Run it! You will now see your window. Qt automatically creates a window with the normal window decorations and you can drag it around and resize it like any window. What you'll see will depend on what platform you're running this example on. The image below shows the window as displayed on Windows, macOS and Linux (Ubuntu). Our window, as seen on Windows, macOS and Linux. Stepping through the code Let's step through the code line by line, so we understand exactly what is happening. First, we import the PyQt classes that we need for the application. Here we're importing QApplication, the application handler and QWidget, a basic empty GUI widget, both from the QtWidgets module. python from PyQt5.QtWidgets import QApplication, QWidget The main modules for Qt are QtWidgets, QtGui and QtCore. You could do from import * but this kind of global import is generally frowned upon in Python, so we'll avoid it here. Next we create an instance of QApplication, passing in sys.arg, which is Python list containing the command line arguments passed to the application. python app = QApplication(sys.argv) If you know you won't be using command line arguments to control Qt you can pass in an empty list instead, e.g. python app = QApplication([]) Next we create an instance of a QWidget using the variable name window. python window = QWidget() window.show() In Qt all top level widgets are windows -- that is, they don't have a parent and are not nested within another widget or layout. This means you can technically create a window using any widget you like. Widgets without a parent are invisible by default. So, after creating the window object, we must always call .show() to make it visible. You can remove the .show() and run the app, but you'll have no way to quit it! What is a window? - Holds the user-interface of your application - Every application needs at least one (...but can have more) - Application will (by default) exit when last window is closed Finally, we call app.exec() to start up the event loop. In PyQt5 you can also use app.exec_(). This was a legacy feature avoid a clash with the exec reserved word in Python 2. What's the event loop? Before getting the window on the screen, there are a few key concepts to introduce about how applications are organized in the Qt world. If you're already familiar with event loops you can safely skip to the next section. The core of every Qt Applications is the QApplication class. Every application needs one — and only one — QApplication object to function. This object holds the event loop of your application — the core loop which governs all user interaction with the GUI. Each interaction with your application — whether a press of a key, click of a mouse, or mouse movement — generates an event which is placed on the event queue. In the event loop, the queue is checked on each iteration and if a waiting event is found, the event and control is passed to the specific event handler for the event. The event handler deals with the event, then passes control back to the event loop to wait for more events. There is only one running event loop per application. The QApplication class - QApplication holds the Qt event loop - One QApplication instance required - You application sits waiting in the event loop until an action is taken - There is only one event loop running at any time QMainWindow As we discovered in the last part, in Qt any widgets can be windows. For example, if you replace QtWidget with QPushButton. In the example below, you would get a window with a single push-able button in it. python import sys from PyQt5.QtWidgets import QApplication, QPushButton app = QApplication(sys.argv) window = QPushButton("Push Me") window.show() app.exec() This is neat, but not really very useful -- it's rare that you need a UI that consists of only a single control! But, as we'll discover later, the ability to nest widgets within other widgets using layouts means you can construct complex UIs inside an empty QWidget. But, Qt already has a solution for you -- the QMainWindow. This is a pre-made widget which provides a lot of standard window features you'll make use of in your apps, including toolbars, menus, a statusbar, dockable widgets and more. We'll look at these advanced features later, but for now, we'll add a simple empty QMainWindow to our application. python import sys from PyQt5.QtWidgets import QApplication, QMainWindow app = QApplication(sys.argv) window = QMainWindow() window.show() # Start the event loop. app.exec() Run it! You will now see your main window. It looks exactly the same as before! So our QMainWindow isn't very interesting at the moment. We can fix that by adding some content. If you want to create a custom window, the best approach is to subclass QMainWindow and then include the setup for the window in the __init__ block. This allows the window behavior to be self contained. We can add our own subclass of QMainWindow — call it MainWindow to keep things simple. python import sys from PyQt5.QtCore import QSize, Qt from PyQt5.QtWidgets import QApplication, QMainWindow, QPushButton # Subclass QMainWindow to customize your application's main window class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("My App") button = QPushButton("Press Me!") # Set the central widget of the Window. self.setCentralWidget(button) app = QApplication(sys.argv) window = MainWindow() window.show() app.exec() For this demo we're using a QPushButton. The core Qt widgets are always imported from the QtWidgets namespace, as are the QMainWindow and QApplication classes. When using QMainWindow we use .setCentralWidget to place a widget (here a QPushButton) in the QMainWindow -- by default it takes the whole of the window. We'll look at how to add multiple widgets to windows in the layouts tutorial. When you subclass a Qt class you must always call the super __init__ function to allow Qt to set up the object. In our __init__ block we first use .setWindowTitle() to change the title of our main window. Then we add our first widget — a QPushButton — to the middle of the window. This is one of the basic widgets available in Qt. When creating the button you can pass in the text that you want the button to display. Finally, we call .setCentralWidget() on the window. This is a QMainWindow specific function that allows you to set the widget that goes in the middle of the window. Run it! You will now see your window again, but this time with the QPushButton widget in the middle. Pressing the button will do nothing, we'll sort that next. Our QMainWindow with a single QPushButton on Windows, macOS and Linux. We'll cover more widgets in detail shortly but if you're impatient and would like to jump ahead you can take a look at the http://doc.qt.io/qt-5/widget-classes.html#basic-widget-classes[QWidget documentation]. Try adding the different widgets to your window! Sizing windows and widgets The window is currently freely resizable -- if you grab any corner with your mouse you can drag and resize it to any size you want. While it's good to let your users resize your applications, sometimes you may want to place restrictions on minimum or maximum sizes, or lock a window to a fixed size. In Qt sizes are defined using a QSize object. This accepts width and height parameters in that order. For example, the following will create a fixed size window of 400x300 pixels. python import sys from PyQt5.QtCore import QSize, Qt from PyQt5.QtWidgets import QApplication, QMainWindow, QPushButton # Subclass QMainWindow to customize your application's main window class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("My App") button = QPushButton("Press Me!") self.setFixedSize(QSize(400, 300)) # Set the central widget of the Window. self.setCentralWidget(button) app = QApplication(sys.argv) window = MainWindow() window.show() app.exec() Run it! You will see a fixed size window -- try and resize it, it won't work. Our fixed-size window, notice that the _maximize control is disabled on Windows & Linux. On macOS you can maximize the app to fill the screen, but the central widget will not resize._ As well as .setFixedSize() you can also call .setMinimumSize() and .setMaximumSize() to set the minimum and maximum sizes respectively. Experiment with this yourself! You can use these size methods on any widget. In this section we've covered the QApplication class, the QMainWindow class, the event loop and experimented with adding a simple widget to a window. In the next section we'll take a look at the mechanisms Qt provides for widgets and windows to communicate with one another and your own code. More