From 797e036ad50c6f60831e522f0bc1395980847ab1 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 | 107 ++++++++++++++ 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 + .../content/10-howto/80-standalone.md | 131 ++++++++++++++++++ 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 +- 10 files changed, 425 insertions(+), 3 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 solara/website/pages/documentation/advanced/content/10-howto/80-standalone.md 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 d6dfdc619..8b4fd1929 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -334,6 +334,113 @@ jobs: path: ./**/${{ env.LOCK_FILE_LOCATION }} include-hidden-files: true + 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 --quiet || echo "HAS_DIFF=true" >> "$GITHUB_OUTPUT" + git diff | tee ${{ env.DIFF_FILE_LOCATION }} + [ -s ${{ env.DIFF_FILE_LOCATION }} ] && echo "HAS_DIFF=true" >> "$GITHUB_OUTPUT" || echo "No dependencies changed" + + + + - 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/solara/website/pages/documentation/advanced/content/10-howto/80-standalone.md b/solara/website/pages/documentation/advanced/content/10-howto/80-standalone.md new file mode 100644 index 000000000..6a397046a --- /dev/null +++ b/solara/website/pages/documentation/advanced/content/10-howto/80-standalone.md @@ -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. 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