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 5, 2024
1 parent 864939d commit 13ba849
Show file tree
Hide file tree
Showing 9 changed files with 291 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
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>
68 changes: 68 additions & 0 deletions tests/qtapp/solara-qt-test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import sys
import threading
from time import sleep
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


HERE = Path(__file__).parent


@click.command()
@click.option(
"--port",
default=int(os.environ.get("PORT", 0)),
help="Port to run the server on, 0 for a random free port",
)
def run(port: int):
sys.path.append(str(HERE))
os.environ["SOLARA_APP"] = "test_app"
import test_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()

def test_success(value):
print("test output", value)
# calling app.quit seems to fail on windows and linux
# possibly because we are in a non-qt-thread (solara)
# app.quit()
QtCore.QMetaObject.invokeMethod(app, "quit", QtCore.Qt.QueuedConnection)
server.stop_serving()

test_app.callback = test_success # type: ignore

failed = False

def fail_guard():
sleep(10)
nonlocal failed
print("failed")
# similar as above
QtCore.QMetaObject.invokeMethod(app, "quit", QtCore.Qt.QueuedConnection)
failed = True

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

threading.Thread(target=fail_guard, daemon=True).start()
app.exec_()
if failed:
sys.exit(1)


if __name__ == "__main__":
run()
18 changes: 18 additions & 0 deletions tests/qtapp/test_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import solara
import solara.lab


def callback(event):
print("Event received:", event)


@solara.component_vue("render_test.vue")
def RenderTest(event_rendered):
pass


@solara.component
def Page():
RenderTest(event_rendered=callback)
# make sure vue components of solara are working
solara.lab.ThemeToggle()
6 changes: 3 additions & 3 deletions tests/unit/file_browser_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ def Test():
list: solara.components.file_browser.FileListWidget = div.children[1]
items = list.files
names = {k["name"] for k in items}
assert names == {"unit", "ui", "docs", "integration", "pyinstaller", ".."}
assert names == {"unit", "ui", "docs", "integration", "pyinstaller", "qtapp", ".."}


def test_file_browser_test_change_directory():
Expand Down Expand Up @@ -212,11 +212,11 @@ def set_directory(path: Path) -> None:
file_list.observe(mock, "files")
items = file_list.files
names = {k["name"] for k in items}
assert names == {"unit", "ui", "docs", "integration", "pyinstaller", ".."}
assert names == {"unit", "ui", "docs", "integration", "pyinstaller", "qtapp", ".."}
file_list.test_click("..")
assert mock.call_count == 0
file_list.test_click("integration")
items = file_list.files
names = {k["name"] for k in items}
assert names != {"unit", "ui", "docs", "integration", "pyinstaller", ".."}
assert names != {"unit", "ui", "docs", "integration", "pyinstaller", "qtapp", ".."}
assert mock.call_count == 1

0 comments on commit 13ba849

Please sign in to comment.