Skip to content

Commit

Permalink
feat: support and test qt + solara + pyinstaller
Browse files Browse the repository at this point in the history
This way we know we can support standalone binaries using
pyinstaller and qt.
  • Loading branch information
maartenbreddels committed Sep 10, 2024
1 parent 8d815d7 commit 369164b
Show file tree
Hide file tree
Showing 10 changed files with 422 additions and 4 deletions.
105 changes: 104 additions & 1 deletion .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,6 @@ jobs:
# pefile >= 2023.2.7 make pyinstaller incredibly slow https://github.com/erocarrera/pefile/issues/420
pip install "jupyterlab<4" "pydantic<2" "playwright==1.41.2" pyinstaller pefile==2023.2.7
pip freeze --exclude solara --exclude solara-ui --exclude solara-server > ${{ env.LOCK_FILE_LOCATION }}
git diff --quiet || echo "HAS_DIFF=true" >> "$GITHUB_OUTPUT"
git diff --exit-code | tee ${{ env.DIFF_FILE_LOCATION }}
[ -s ${{ env.DIFF_FILE_LOCATION }} ] && echo "HAS_DIFF=true" >> "$GITHUB_OUTPUT"
Expand Down Expand Up @@ -330,6 +329,110 @@ jobs:
name: ci-package-locks-pyinstaller-os${{ matrix.os }}-python${{ matrix.python-version }}
path: ./**/${{ env.LOCK_FILE_LOCATION }}

qt-test:
needs: [build]
timeout-minutes: 15
runs-on: ${{ matrix.os }}-latest
strategy:
fail-fast: false
matrix:
os: [macos, windows, ubuntu]
# only 1 version, it's heavy
python-version: ["3.10"]
env:
LOCK_FILE_LOCATION: .ci-package-locks/qt-test/os${{ matrix.os }}-python${{ matrix.python-version }}.txt
steps:
- uses: ConorMacBride/install-package@v1
with:
# mirrored from glue-qt
# https://github.com/glue-viz/glue-qt/blob/main/.github/workflows/ci_workflows.yml
# using
# https://github.com/OpenAstronomy/github-actions-workflows/blob/5edb24fa432c75c0ca723ddea8ea14b72582919d/.github/workflows/tox.yml#L175C15-L175C49
# Linux PyQt 5.15 and 6.x installations require apt-getting xcb and EGL deps
# and headless X11 display;
apt: '^libxcb.*-dev libxkbcommon-x11-dev libegl1-mesa libopenblas-dev libhdf5-dev'

- name: Setup headless display
uses: pyvista/setup-headless-display-action@v2

- uses: actions/checkout@v4

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: "pip"

- uses: actions/download-artifact@v4
with:
name: solara-builds-${{ github.run_number }}

- name: Link solara app package
if: matrix.os != 'windows'
run: |
cd packages/solara-vuetify-app
npm run devlink
- name: Copy solara app package
if: matrix.os == 'windows'
run: |
cd packages/solara-vuetify-app
npm run wincopy
- name: Prepare
id: prepare
run: |
mkdir test-results
if [ -f ${{ env.LOCK_FILE_LOCATION }} ]; then
echo "LOCKS_EXIST=true" >> "$GITHUB_OUTPUT"
else
echo "LOCKS_EXIST=false" >> "$GITHUB_OUTPUT"
fi
- name: Install without locking versions
if: github.event_name == 'schedule' || steps.prepare.outputs.LOCKS_EXIST == 'false'
id: install_no_lock
run: |
mkdir -p .ci-package-locks/qt-test
# see https://github.com/erocarrera/pefile/issues/420 for performance issues on
# windows for pefile == 2024.8.26
pip install pyside6 qtpy pyinstaller "pefile<2024.8.26"
pip install `echo dist/*.whl`[all]
pip install `echo packages/solara-server/dist/*.whl`[all]
pip install `echo packages/solara-meta/dist/*.whl`[dev,documentation]
pip freeze --exclude solara --exclude solara-ui --exclude solara-server > ${{ env.LOCK_FILE_LOCATION }}
git diff --exit-code | tee ${{ env.DIFF_FILE_LOCATION }}
[ -s ${{ env.DIFF_FILE_LOCATION }} ] && echo "HAS_DIFF=true" >> "$GITHUB_OUTPUT"
- name: Install
if: github.event_name != 'schedule' && steps.prepare.outputs.LOCKS_EXIST == 'true'
run: |
pip install -r ${{ env.LOCK_FILE_LOCATION }}
pip install `echo dist/*.whl`[all]
pip install `echo packages/solara-server/dist/*.whl`[all]
pip install `echo packages/solara-meta/dist/*.whl`[dev,documentation]
- name: test qt app
if: github.event_name != 'schedule' || steps.install_no_lock.outputs.HAS_DIFF == 'true'
# this app should simply exit with an error code of 0 to indicate success
run: |
python tests/qtapp/solara-qt-test.py
- name: Test solara+qt+pyinstaller
run: |
(cd pyinstaller/embedded_browser; pyinstaller ./solara-qt.spec)
./pyinstaller/embedded_browser/dist/solara-qt/solara-qt
- name: Upload Test artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results-qt-test-os${{ matrix.os }}-python${{ matrix.python-version }}
path: test-results

- name: Upload CI package locks
if: steps.install_no_lock.outputs.HAS_DIFF == 'true' || steps.prepare.outputs.LOCKS_EXIST == 'false'
uses: actions/upload-artifact@v4
with:
name: ci-package-locks-qt-test-os${{ matrix.os }}-python${{ matrix.python-version }}
path: ./**/${{ env.LOCK_FILE_LOCATION }}

integration-test:
needs: [build]
timeout-minutes: 25
Expand Down
1 change: 1 addition & 0 deletions pyinstaller/embedded_browser/render_test.vue
1 change: 1 addition & 0 deletions pyinstaller/embedded_browser/solara-qt-test.py
82 changes: 82 additions & 0 deletions pyinstaller/embedded_browser/solara-qt.spec
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# -*- mode: python ; coding: utf-8 -*-
import sys
from pathlib import Path
import os

from PyInstaller.building.build_main import Analysis
from PyInstaller.building.api import COLLECT, EXE, PYZ
from PyInstaller.building.osx import BUNDLE

import solara
# see https://github.com/spacetelescope/jdaviz/blob/main/.github/workflows/standalone.yml
# for an example of how to sign the app for macOS
codesign_identity = os.environ.get("DEVELOPER_ID_APPLICATION")

# this copies over the nbextensions enabling json and the js assets
# for all the widgets
datas = [
(Path(sys.prefix) / "share" / "jupyter", "./share/jupyter"),
(Path(sys.prefix) / "etc" / "jupyter", "./etc/jupyter"),
("render_test.vue", "."),
]

block_cipher = None


a = Analysis(
["solara-qt-test.py"],
pathex=[],
binaries=[],
datas=datas,
hiddenimports=["rich.logging"],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=True,
module_collection_mode={
"test_app": "pyz+py"
},
)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)

exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name="solara-qt",
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=False, # with True, PySide very often does not show the window
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=codesign_identity,
entitlements_file="../entitlements.plist",
)
coll = COLLECT(
exe,
a.binaries,
a.zipfiles,
a.datas,
strip=False,
upx=True,
upx_exclude=[],
# directory name: dist/solara-qt
name="solara-qt",
)
app = BUNDLE(
exe,
coll,
name="solara-qt.app",
icon="../solara.icns",
entitlements_file="../entitlements.plist",
bundle_identifier="com.widgetti.solara",
version=solara.__version__,
)
1 change: 1 addition & 0 deletions pyinstaller/embedded_browser/test_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
---
title: How to create a standalone binary (.exe file) with PyInstaller.
description: Create a standalone binary (.exe file) with PyInstaller similar to an Electron app, such as VSCode or Slack.
---
# How to create a standalone binary (.exe) with PyInstaller

PyInstaller is a tool that bundles a Python application and its dependencies into a single package. This package can be run on a different machine without needing to install Python or the dependencies. This is useful for distributing applications that run on the computer of a user, instead of a server, without needing to install Python on the user's machine.

Since Solara is a web framework, it also needs a browser to run. In this case, we are going to use Qt's integrated browser, to produce a fully standalone application, similar to an Electron app, such as VSCode or Slack.

Although in principle Electron could be used, by using Qt, we can have the browser and the server running in the same process, making it easier to create native menu items and have communication between the browser and the server.

## PyQt vs PySide

There are two Python libraries for using Qt: PyQt and PySide. PyQt is developed by Riverbank Computing and is available under two licenses: GPL and commercial. PySide is developed by the Qt Company and is available under the LGPL license. The LGPL license allows you to distribute the library with your application without needing to open-source your application. This is the license we are going to use in this example. Note that if you use the qtpy library, you can switch between PyQt and PySide without changing your code.


## Installation

```
# NOTE: the pefile version is pinned to avoid performance issue on windows at the time of writing
# see https://github.com/erocarrera/pefile/issues/420
pip install solara pyside6 qtpy pyinstaller "pefile<2024.8.26" click
```

## Solara app

We will use a very simply Solara app to demonstrate how to create a standalone binary.

Create a file called `app.py` with the following content:
```python
import solara

clicks = solara.reactive(0)


@solara.component
def Page():
color = "green"
if clicks.value >= 5:
color = "red"

def increment():
clicks.value += 1
print("clicks", clicks) # noqa

solara.Button(label=f"Clicked: {clicks}", on_click=increment, color=color)
```

Or run the following command to create the file:
```bash
$ solara create button app.py
Wrote: /home/myname/my-solara-project/app.py
Run as:
$ solara run /home/myname/my-solara-project/app.py
```


## Application script

This part is responsible for interpreting the command line arguments, starting the Solara server, and creating the Qt application with the embedded browser that will render the Solara app.

Create a file called `my-solara-app.py` with the following content:
```python
import sys
from pathlib import Path

import click
import os

# make sure you use pyside when distributing your app without having to use a GPL license
from qtpy.QtWidgets import QApplication
from qtpy.QtWebEngineWidgets import QWebEngineView
from qtpy import QtCore

# make sure PyInstaller includes 'app.py'
import app


HERE = Path(__file__).parent


@click.command()
@click.option("--port", default=0, help="Port to run the server on, 0 for a random free port")
def run(port: int):
os.environ["SOLARA_APP"] = "app"

import solara.server.starlette

server = solara.server.starlette.ServerStarlette(host="localhost", port=port)
print(f"Starting server on {server.base_url}")
server.serve_threaded()
server.wait_until_serving()

app = QApplication([""])
web = QWebEngineView()
web.setUrl(QtCore.QUrl(server.base_url))
web.show()
app.exec_()


if __name__ == "__main__":
run()
```

You should now be able to run the application with the following command:
```bash
$ python my-solara-app.py
```

Which on MacOS should show up like this:

![Solara standalone app](https://solara-assets.s3.us-east-2.amazonaws.com/public/docs/howto/solara-qt.webp)


## Creating the standalone binary

Now that we have the application script, we can use PyInstaller to create a standalone binary.

```
$ pyinstaller my-solara-app.py --windowed
$ open ./dist/my-solara-app.app # on MacOS
$ ./dist/my-solara-app.app/Contents/MacOS/my-solara-app # on MacOS, but keeping the terminal open
```

This .app file can be distributed to other users, and they can run it without needing to install Python or any dependencies. Similarly, on Windows, you can distribute the .exe file or
create an installer, and on Linux, you can distribute the directory (possibly zipped).

However, for MacOS, you may need to sign the app to avoid the "unidentified developer" warning. Doing this is out the scope of this guide, but once you arrive at this point, you might want
to automate the process in CI. The [GitHub action workflow for Jdaviz](https://github.com/spacetelescope/jdaviz/blob/main/.github/workflows/standalone.yml) is a good example on how to set
this up. It does require an Apple Developer account however to do proper code signing.
13 changes: 13 additions & 0 deletions tests/qtapp/render_test.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<template>
<div>If you see this, it is all working. The Qt app should automatically close after a second.</div>
</template>
<script>
module.exports = {
mounted() {
console.log("mounted")
setTimeout(() => {
this.rendered()
}, 1000)
}
}
</script>
Loading

0 comments on commit 369164b

Please sign in to comment.