From 30d2d9a1b1b187f9eed52b1a181cb2bf04ce037d Mon Sep 17 00:00:00 2001 From: "Maarten A. Breddels" Date: Tue, 30 Jul 2024 16:33:14 +0200 Subject: [PATCH] feat: support and test qt + solara + pyinstaller This way we know we can support standalone binaries using pyinstaller and qt. --- .github/workflows/test.yaml | 105 +++++++++++++++++- pyinstaller/embedded_browser/render_test.vue | 1 + .../embedded_browser/solara-qt-test.py | 1 + pyinstaller/embedded_browser/solara-qt.spec | 82 ++++++++++++++ pyinstaller/embedded_browser/test_app.py | 1 + tests/qtapp/render_test.vue | 13 +++ tests/qtapp/solara-qt-test.py | 68 ++++++++++++ tests/qtapp/test_app.py | 18 +++ tests/unit/file_browser_test.py | 6 +- 9 files changed, 291 insertions(+), 4 deletions(-) create mode 120000 pyinstaller/embedded_browser/render_test.vue create mode 120000 pyinstaller/embedded_browser/solara-qt-test.py create mode 100644 pyinstaller/embedded_browser/solara-qt.spec create mode 120000 pyinstaller/embedded_browser/test_app.py create mode 100644 tests/qtapp/render_test.vue create mode 100644 tests/qtapp/solara-qt-test.py create mode 100644 tests/qtapp/test_app.py diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index b8a9e045c..bbd4e9eac 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -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" @@ -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 diff --git a/pyinstaller/embedded_browser/render_test.vue b/pyinstaller/embedded_browser/render_test.vue new file mode 120000 index 000000000..490700886 --- /dev/null +++ b/pyinstaller/embedded_browser/render_test.vue @@ -0,0 +1 @@ +../../tests/qtapp/render_test.vue \ No newline at end of file diff --git a/pyinstaller/embedded_browser/solara-qt-test.py b/pyinstaller/embedded_browser/solara-qt-test.py new file mode 120000 index 000000000..706f2019e --- /dev/null +++ b/pyinstaller/embedded_browser/solara-qt-test.py @@ -0,0 +1 @@ +../../tests/qtapp/solara-qt-test.py \ No newline at end of file diff --git a/pyinstaller/embedded_browser/solara-qt.spec b/pyinstaller/embedded_browser/solara-qt.spec new file mode 100644 index 000000000..a57f8d019 --- /dev/null +++ b/pyinstaller/embedded_browser/solara-qt.spec @@ -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__, +) diff --git a/pyinstaller/embedded_browser/test_app.py b/pyinstaller/embedded_browser/test_app.py new file mode 120000 index 000000000..2bf49c710 --- /dev/null +++ b/pyinstaller/embedded_browser/test_app.py @@ -0,0 +1 @@ +../../tests/qtapp/test_app.py \ No newline at end of file diff --git a/tests/qtapp/render_test.vue b/tests/qtapp/render_test.vue new file mode 100644 index 000000000..4aaa87ec0 --- /dev/null +++ b/tests/qtapp/render_test.vue @@ -0,0 +1,13 @@ + + diff --git a/tests/qtapp/solara-qt-test.py b/tests/qtapp/solara-qt-test.py new file mode 100644 index 000000000..25d4fcb4b --- /dev/null +++ b/tests/qtapp/solara-qt-test.py @@ -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() diff --git a/tests/qtapp/test_app.py b/tests/qtapp/test_app.py new file mode 100644 index 000000000..2c3020e48 --- /dev/null +++ b/tests/qtapp/test_app.py @@ -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() diff --git a/tests/unit/file_browser_test.py b/tests/unit/file_browser_test.py index 4314842e2..98927da15 100644 --- a/tests/unit/file_browser_test.py +++ b/tests/unit/file_browser_test.py @@ -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(): @@ -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