Linux applications with Python and QML

Φεβ 26 2022

Linux applications with QML and Python? Why not? Python is a popular programming language. QML offers an intuitive way to create user interfaces. Kirigami provides useful UI components and implements UI/UX patterns for mobile and desktop. Let's fit these technologies together and create a simple application.

Prerequisites

Obviously, we need Python. Most Linux distributions provide a recent Python. In the rare case that you have to install it by yourself, you may find here some tips. For QML and Kirigami, check the Kirigami introduction guide.

Between Python and QML there is Qt for Python. It offers Python bindings for Qt, and it consists of:

  1. PySide2, so that you can use Qt5 APIs in Python
  2. Shiboken2, a binding generator tool which exposes C++ to Python

On Ubuntu and the like, python3-pyside2.qtcore, python3-pyside2.qtqml and python3-pyside2.qtwidgets are enough for the code of this article. Different distributions may package or name the various bindings differently. E.g., on postmarketOS, everything we need is found in the py3-pyside2 package.

Development

Let's create a simple program to see these technologies in action: a markdown viewer. The UI will be created in QML and the logic in Python. Users will write some markdown text, press a button, and the formatted text will be shown.

Nothing fancy.

It is recommended to use a virtual environment. The venv module provides support for virtual environments with their own site directories, optionally isolated from system site directories. On Ubuntu flavors and derivatives, it is provided by the python3.8-venv package.

  • Create a directory and a virtual environment for the project:
mkdir simple-md-viewer-project
cd simple-md-viewer-project
python3 -m venv --system-site-packages env/
  • Activate it using the activate script:
source env/bin/activate

We can verify that we are working in a virtual environment by checking the VIRTUAL_ENV environment variable.

It's time to write some code. A PySide2/QML application consists, at least, of two files: a file with the QML description of the user interface, and a Python file that loads the QML file.

  • Create a new simplemdviewer directory into simple-md-viewer-project, and add a new simplemdviewer_app.py file in this directory:
import os
from PySide2.QtGui import QGuiApplication
from PySide2.QtCore import QUrl
from PySide2.QtQml import QQmlApplicationEngine, qmlRegisterType

def main():
    """Initializes and manages the application execution"""
    app = QGuiApplication()
    engine = QQmlApplicationEngine()

    base_path = os.path.abspath(os.path.dirname(__file__))
    url = QUrl(f'file://{base_path}/qml/main.qml')
    engine.load(url)

    if len(engine.rootObjects()) == 0:
        quit()

    app.exec_()

if __name__ == "__main__":
    main()

simplemdviewer_app.py

We have just created a QGuiApplication object that, among others, initializes the application and contains the main event loop. The QQmlApplicationEngine object loads the QML main.qml file.

  • Create a new qml directory in the simplemdviewer one and add a new main.qml file that specifies the UI of the application:
import QtQuick 2.12
import QtQuick.Controls 2.12 as Controls
import org.kde.kirigami 2.12 as Kirigami
import QtQuick.Layouts 1.12

Kirigami.ApplicationWindow {
    id: root

    title: qsTr("Simple markdown viewer")

    minimumWidth: Kirigami.Units.gridUnit * 20
    minimumHeight: Kirigami.Units.gridUnit * 20

    pageStack.initialPage: initPage

    Component {
        id: initPage

        Kirigami.Page {
            title: qsTr("Markdown Viewer")

            ColumnLayout {
                anchors {
                    top: parent.top
                    left: parent.left
                    right: parent.right
                }
                Controls.TextArea {
                    id: sourceArea

                    placeholderText: qsTr("Write here some markdown code")
                    wrapMode: Text.WrapAnywhere
                    Layout.fillWidth: true
                    Layout.minimumHeight: Kirigami.Units.gridUnit * 5 
                }

                RowLayout {
                    Layout.fillWidth: true

                    Controls.Button {
                        text: qsTr("Format")

                        onClicked: formattedText.text = sourceArea.text
                    }

                    Controls.Button {
                        text: qsTr("Clear")

                        onClicked: {
                            sourceArea.text = ""
                            formattedText.text = ""
                        }
                    }
                } 

                Text {
                    id: formattedText

                    textFormat: Text.RichText
                    wrapMode: Text.WordWrap
                    text: sourceArea.text

                    Layout.fillWidth: true
                    Layout.minimumHeight: Kirigami.Units.gridUnit * 5
                }
            }
        }
    }
}

main.qml

We have just created a new QML-Kirigami-Python application.

Cool.

  • Run it:
python3 simplemdviewer_app.py

It shows a page split into two sections. On the top one you write some markdown code. Upon clicking on Format, the formated text will be displayed on the bottom of the page.

Simple Markdown Viewer A simple Python QML application

At the moment we have not used any Python interesting stuff. In reality, the application can also run as a standalone QML one:

qmlscene qml/main.qml

It does not format anything; if we click on Format it just spits the unformatted text into a text element.

Nothing special Nothing special

OK, let's add some Python logic: a simple markdown converter in a Python, QObject derivative class.

  • Create a new md_converter.py file in the simplemdviewer directory:
from markdown import markdown
from PySide2.QtCore import QObject, Signal, Slot, Property

class MdConverter(QObject):
    """A simple markdown converter"""

    def __init__(self):
        QObject.__init__(self)

        self._source_text = ''

    def readSourceText(self):
        return self._source_text

    def setSourceText(self, val):
        self._source_text = val
        self.sourceTextChanged.emit()

    @Signal
    def sourceTextChanged(self):
        pass

    @Slot(result=str)
    def mdFormat(self):
        return markdown(self._source_text)

    sourceText = Property(str, readSourceText, setSourceText, notify=sourceTextChanged)

md_converter.py

The MdConverter class contains the source_text member variable. The sourceText property exposes source_text to the QML system through the readSouceText getter and the setSourceText setter functions. When setting the sourceText, the sourceTextChanged signal is emitted to let QML know that the property has changed. The mdFormat function returns the markdown formatted text and it has been declared as Slot so as to be invokable by the QML code.

The markdown Python package takes care of formatting. Let's install it in our virtual environment.

  • Execute:
pip install markdown

It is time to register the new MdConverter type.

  • Update the simplemdviewer_app.py file to:
import os
from PySide2.QtGui import QGuiApplication
from PySide2.QtCore import QUrl
from PySide2.QtQml import QQmlApplicationEngine, qmlRegisterType
from md_converter import MdConverter

def main():
    """Initializes and manages the application execution"""
    app = QGuiApplication()
    engine = QQmlApplicationEngine()

    qmlRegisterType(MdConverter, 'org.mydomain.simplemdviewer',
                    1, 0, 'MdConverter')

    base_path = os.path.abspath(os.path.dirname(__file__))
    url = QUrl(f'file://{base_path}/qml/main.qml')
    engine.load(url)

    if len(engine.rootObjects()) == 0:
        quit()

    app.exec_()

if __name__ == "__main__":
    main()

simplemdviewer_app.py

The qmlRegisterType function has registered the MdCoverter type in the QML system, in the library org.mydomain.simplemdviewer, version 1.0.

  • Change the qml/main.qml file to:
import QtQuick 2.12
import QtQuick.Controls 2.12 as Controls
import org.kde.kirigami 2.12 as Kirigami
import QtQuick.Layouts 1.12
import org.mydomain.simplemdviewer 1.0

Kirigami.ApplicationWindow {
    id: root

    title: qsTr("Simple markdown viewer")

    minimumWidth: Kirigami.Units.gridUnit * 20
    minimumHeight: Kirigami.Units.gridUnit * 20

    pageStack.initialPage: initPage

    Component {
        id: initPage

        Kirigami.Page {
            title: qsTr("Markdown Viewer")

            MdConverter {
                id: mdconverter

                sourceText: sourceArea.text
            }

            ColumnLayout {
                anchors {
                    top: parent.top
                    left: parent.left
                    right: parent.right
                }
                Controls.TextArea {
                    id: sourceArea

                    placeholderText: qsTr("Write here some markdown code")
                    wrapMode: Text.WrapAnywhere
                    Layout.fillWidth: true
                    Layout.minimumHeight: Kirigami.Units.gridUnit * 5 
                }

                RowLayout {
                    Layout.fillWidth: true

                    Controls.Button {
                        text: qsTr("Format")

                        onClicked: formattedText.text = mdconverter.mdFormat()
                    }

                    Controls.Button {
                        text: qsTr("Clear")

                        onClicked: {
                            sourceArea.text = ""
                            formattedText.text = ""
                        }
                    }
                } 

                Text {
                    id: formattedText

                    textFormat: Text.RichText
                    wrapMode: Text.WordWrap

                    Layout.fillWidth: true
                    Layout.minimumHeight: Kirigami.Units.gridUnit * 5
                }
            }
        }
    }
}

main.qml

The updated QML code: 1. imports the org.mydomain.simplemdviewer library 2. creates an MdConverter object 3. updates the onClicked signal handler of the Format button to call the mdFormat function of the converter object

  • Change directory to simplemdviewer and execute:
python3 simplemdviewer_app.py
  • Play with adding some markdown text: Simple Markdown Viewer Simple Markdown viewer in action

Hooray.

Package the application

To distribute the application to the users we have to package it. We are going to use the setuptools library.

  • Create an empty __init__.py file in the simplemdviewer directory.

This file is required to import a directory as a package. The project structure should look like this:

simple-md-viewer-project/
├── env
└── simplemdviewer
    ├── __init__.py
    ├── md_converter.py
    ├── qml
       └── main.qml
    └── simplemdviewer_app.py
  • Create a pyproject.toml file in the simple-md-viewer-project directory, to tell build tools what is needed to build our project:
[build-system]
requires = ["setuptools", "wheel"]
build-backend = "setuptools.build_meta"

pyproject.toml

Time to add the configuration file for setuptools.

  • Create a setup.py into the simple-md-viewer-project directory:
from setuptools import setup
setup()

setup.py

  • Add a new setup.cfg file into the simple-md-viewer-project directory:
[metadata]
name = org.mydomain.simplemdviewer
version = 0.1
url = https://mydomain.org/simplemdviewer
author= Example Author
author_email = example@author.org
maintainer = Example Author
maintainer_email = example@author.org
description = A simple markdown viewer
long_description = file: README.md
long_description_content_type = text/markdown
classifiers =
    Development Status :: 5 - Production/Stable
    License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
    Intended Audience :: End Users/Desktop
    Topic :: Utilities
    Programming Language :: Python
    Operating System :: POSIX :: Linux
keywords= viewer converter markdown

[options]
packages = simplemdviewer
include_package_data = True
install_requires =
    requests
    importlib; python_version >= "3.8"
    markdown

setup.cfg

In the metadata section we have provided information about the application. The options section contains the project dependencies and the import package that our distribution package is going to provide. There is a lot of options and classifiers available. For more details on dependencies management in setuptools, check here.

It is good to have a README.md file as well.

  • Create a README.md file in the simple-md-viewer-project directory:
# Simple Markdown Viewer

A simple markdown viewer created with Kirigami, QML and Python

README.md

Another important piece is the license of our project.

  • Create a LICENCE.txt file and add the text of the license of our project.

Apart from the Python stuff, we have to add the QML code to the distribution package as well.

  • Create a MANIFEST.in file in the simple-md-viewer-project directory, and add:
include simplemdviewer/qml/*.qml

MANIFEST.in

Some last pieces and we are ready to build. We are going to add:

  1. the appstream metadata to be used by the software centers
  2. a desktop entry file to add the application to the application launcher
  3. an application icon

  4. Create a new org.mydomain.simplemdviewer.desktop file into the simple-md-viewer-project directory:

[Desktop Entry]
Version=0.1
Type=Application
Name=Simple Markdown Viewer
Name[x-test]=xxSimple Markdown Viewerxx
GenericName=Markdown Viewer
GenericName[x-test]=xxMarkdown Viewerxx
Comment=A simple markdown viewer application
Comment[x-test]=xxA simple markdown viewer applicationxx
Exec=simplemdviewer
Icon=org.mydomain.simplemdviewer
Terminal=false
Categories=Office;
X-KDE-FormFactor=desktop;tablet;handset;

org.mydomain.simplemdviewer.desktop

  • Add a new org.mydomain.simplemdviewer.metainfo.xml file into the simple-md-viewer-project directory:
<?xml version="1.0" encoding="utf-8"?>
<component type="desktop">
  <id>org.mydomain.simplemdviewer</id>
  <metadata_license>CC0-1.0</metadata_license>
  <project_license>GPL-3.0+</project_license>
  <name>Simple Markdown Viewer</name>
  <summary>A simple markdown viewer application</summary>
  <description>
    <p>Simple Markdown Viewer is a showcase application for QML with Python development</p>
  </description>
  <url type="homepage">https://mydomain.org/simplemdviewer</url>
  <releases>
    <release version="0.1" date="2022-02-25">
      <description>
        <p>First release</p>
      </description>
    </release>
  </releases>
  <provides>
    <binary>simplemdviewer</binary>
  </provides>
</component>

org.mydomain.simplemdviewer.metainfo.xml

For our tutorial the well known markdown icon is OK.

  • Get the markdown icon and save it as org.mydomain.simplemdviewer.svg into the simple-md-viewer-project directory.

Now we have to let setup.cfg know about the new files. Let's also provide an easy way to open the application from the console by just typing simplemdviewer.

  • Update the setup.cfg to:
[metadata]
name = org.mydomain.simplemdviewer
version = 0.1
url = https://mydomain.org/simplemdviewer
author= Example Author
author_email = example@author.org
maintainer = Example Author
maintainer_email = example@author.org
description = A simple markdown viewer
long_description = file: README.md
long_description_content_type = text/markdown
classifiers =
    Development Status :: 5 - Production/Stable
    License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
    Intended Audience :: End Users/Desktop
    Topic :: Utilities
    Programming Language :: Python
    Operating System :: POSIX :: Linux
keywords= viewer converter markdown

[options]
packages = simplemdviewer
include_package_data = True
install_requires =
    requests
    importlib; python_version >= "3.8"
    markdown

[options.data_files]
share/applications =
    org.mydomain.simplemdviewer.desktop
share/icons/hicolor/scalable/apps =
    org.mydomain.simplemdviewer.svg
share/metainfo =
     org.mydomain.simplemdviewer.metainfo.xml

[options.entry_points]
console_scripts =
    simplemdviewer = simplemdviewer.simplemdviewer_app:main

setup.cfg (updated)

Last step is to tinker with the way we import modules.

  • Update the simplemdviewer_app.py file to:
import os
from PySide2.QtGui import QGuiApplication
from PySide2.QtCore import QUrl
from PySide2.QtQml import QQmlApplicationEngine, qmlRegisterType
from simplemdviewer.md_converter import MdConverter

def main():
    """Initializes and manages the application execution"""
    app = QGuiApplication()
    engine = QQmlApplicationEngine()

    qmlRegisterType(MdConverter, 'org.mydomain.simplemdviewer',
                    1, 0, 'MdConverter')

    base_path = os.path.abspath(os.path.dirname(__file__))
    url = QUrl(f'file://{base_path}/qml/main.qml')
    engine.load(url)

    if len(engine.rootObjects()) == 0:
        quit()

    app.exec_()

if __name__ == "__main__":
    main()

simplemdviewer_app.py (updated)

  • Create a __main__.py file into the simplemdviewer directory:
from . import simplemdviewer_app

simplemdviewer_app.main()

This last step will facilitate the excecution of the package during development.

  • Execute from inside the simple-md-viewer-project a last check before packaging:
python3 -m simplemdviewer

It's time to generate the distribution package for our import package.

  • Make sure that the latest version of build is installed:
python3 -m pip install --upgrade build
  • Execute from inside the simple-md-viewer-project directory:
python3 -m build

As soon as the build completes, two archives will be created in the dist directory:

  1. the org.mydomain.simplemdviewer-0.1.tar.gz source archive
  2. the org.mydomain.simplemdviewer-0.1-py3-none-any.whl built distribution package

  3. Install the package into the virtual environment:

python3 -m pip install dist/org.mydomain.simplemdviewer-0.1.tar.gz 
  • Run:
simplemdviewer

At this point we can tag and release the source code. The Linux distributions will package it and the application will be added to their software repositories.

Well done.

We can also take care of distributing it. Although the PyPI is probably the standard way to distribute a Python package, I believe that Flatpak is more compatible with the Linux applications ecoystem.

  • Create an org.mydomain.simplemdviewer.json file in the simple-md-viewer-project directory:
{
    "id": "org.mydomain.simplemdviewer",
    "runtime": "org.kde.Platform",
    "runtime-version": "5.15",
    "sdk": "org.kde.Sdk",
    "command": "simplemdviewer",
    "finish-args": [
    "--share=ipc",
    "--socket=fallback-x11",
    "--socket=wayland",
    "--device=dri",
    "--socket=pulseaudio"
    ],
    "modules": [
        "python3-markdown.json",
        {
            "name": "PySide2",
            "buildsystem": "cmake-ninja",
            "builddir": true,
            "config-opts": [
                "-DCMAKE_BUILD_TYPE=Release",
                "-DBUILD_TESTS=OFF"
            ],
            "sources": [
                {
                    "type": "archive",
                    "url": "https://download.qt.io/official_releases/QtForPython/pyside2/PySide2-5.15.2-src/pyside-setup-opensource-src-5.15.2.tar.xz",
                    "sha256": "b306504b0b8037079a8eab772ee774b9e877a2d84bab2dbefbe4fa6f83941418"
                },
                {
                    "type": "shell",
                    "commands": [
                        "mkdir -p /app/include/qt5tmp && cp -R /usr/include/Qt* /app/include/qt5tmp # https://bugreports.qt.io/broswse/PYSIDE-787",
                        "sed -i 's|--include-paths=|--include-paths=/app/include/qt5tmp:|' sources/pyside2/cmake/Macros/PySideModules.cmake"
                    ]
                }
            ]
        },
        {
            "name": "simplemdviewer",
            "buildsystem" : "simple",
            "build-commands" : [
                "python3 setup.py build",
                "python3 setup.py install --prefix=/app --root=/"
            ],
            "sources": [
                {
                    "type": "archive",
                    "path": "dist/org.mydomain.simplemdviewer-0.1.tar.gz"
                }
            ]
        }
    ]
}

org.mydomain.simplemdviewer.json

This file reads that we use the markdown module and the build info is provided by the python3-markdown.json manifest file. We are going to create this manifest using flatpak-pip-generator.

python3 env/bin/flatpak-pip-generator markdown
  • Install org.kde.Sdk and org.kde.Platform, version 5.15, from flathub:
flatpak install org.kde.Platform/x86_64/5.15 org.kde.Sdk/x86_64/5.15
  • Install the flatpak-builder package using the package manager of your distribution and run:
flatpak-builder --verbose --force-clean flatpak-build-dir org.mydomain.simplemdviewer.json 
  • Test the flatpak build:
flatpak-builder --env=QT_QPA_PLATFORM=wayland --run flatpak-build-dir org.mydomain.simplemdviewer.json simplemdviewer
  • Build the flatpak bundle:
flatpak-builder --install-deps-from=flathub flatpak-build-dir --repo=simplemdviewer-master --force-clean --ccache org.mydomain.simplemdviewer.json 
flatpak build-bundle simplemdviewer-master simplemdviewer.flatpak org.mydomain.simplemdviewer

Now we can either distribute simplemdviewer.flatpak directly to the users, or submit the application to a flatpak repository, e.g., on flathub.

Great.

I suggest that you also consider: - Signing the source archive with a detached signature - Providing copyright and licensing information for each file with reuse

The code of the article can be found here.

If you have found this article interesting, share it with your friends on your favorite social platform. I hope this platform is Mastodon.

Happy hacking.