Skip to content

Commit

Permalink
feat: use embedded qt browser for jdaviz standalone (#3188)
Browse files Browse the repository at this point in the history
* feat: add --browser=qt to use an embedded browser instead of system one

This also restore the --browser feature that was previously broken.

* feat: use embedded qt browser for jdaviz standalone

* support ipypopout and give instructions what to install

* ci: pin os versions for building standalone

* fix: hook for dask, which needs jinja templates to be included

* ci: ignore qt module

* fix: exit solara after last kernel closed more reliably
  • Loading branch information
maartenbreddels authored Jan 14, 2025
1 parent a8554a2 commit 87016c7
Show file tree
Hide file tree
Showing 10 changed files with 213 additions and 27 deletions.
31 changes: 24 additions & 7 deletions .github/workflows/standalone.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,11 @@ defaults:

jobs:
build_binary_not_osx:
runs-on: ${{ matrix.os }}-latest
runs-on: ${{ matrix.os }}
if: (github.repository == 'spacetelescope/jdaviz' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || contains(github.event.pull_request.labels.*.name, 'Build standalone')))
strategy:
matrix:
os: [ubuntu, windows]
os: [ubuntu-22.04, windows-latest]
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
Expand All @@ -31,19 +31,36 @@ jobs:
with:
python-version: "3.11"

- 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

- name: Install jdaviz
run: pip install .[test]
run: pip install .[test,qt]

- name: Install pyinstaller
run: pip install "pyinstaller<6"
# see https://github.com/erocarrera/pefile/issues/420 for performance issues on
# windows for pefile == 2024.8.26
# also see https://github.com/widgetti/solara/pull/724
# or https://solara.dev/documentation/advanced/howto/standalone (currently unpublished)
run: pip install "pyinstaller" "pefile<2024.8.26"

- name: Create standalone binary
env:
DEVELOPER_ID_APPLICATION: ${{ secrets.DEVELOPER_ID_APPLICATION }}
run: (cd standalone; pyinstaller ./jdaviz.spec)

- name: Run jdaviz cmd in background
run: ./standalone/dist/jdaviz/jdaviz-cli imviz&
run: ./standalone/dist/jdaviz/jdaviz-cli imviz --port 8765 &

- name: Install playwright
run: (pip install playwright; playwright install chromium)
Expand Down Expand Up @@ -77,11 +94,11 @@ jobs:
# Do not want to deal with OSX certs in pull request builds.
build_binary_osx:
runs-on: ${{ matrix.os }}-latest
runs-on: ${{ matrix.os }}
if: (github.repository == 'spacetelescope/jdaviz' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch'))
strategy:
matrix:
os: [macos]
os: [macos-14]
steps:
# osx signing based on https://melatonin.dev/blog/how-to-code-sign-and-notarize-macos-audio-plugins-in-ci/
- name: Import Certificates (macOS)
Expand Down
34 changes: 23 additions & 11 deletions jdaviz/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,6 @@ def main(filepaths=None, layout='default', instrument=None, browser='default',
# easily accessed e.g. in the file load dialog.
os.environ['JDAVIZ_START_DIR'] = os.path.abspath('.')

from solara.__main__ import cli
from jdaviz import solara
solara.config = layout.capitalize()
solara.data_list = file_list
Expand All @@ -71,16 +70,29 @@ def main(filepaths=None, layout='default', instrument=None, browser='default',
solara.theme = theme
solara.jdaviz_verbosity = verbosity
solara.jdaviz_history_verbosity = history_verbosity
args = []
if hotreload:
args += ['--auto-restart']
run_solara(host=host, port=port, theme=theme, browser=browser, production=not hotreload)


def run_solara(host, port, theme, browser, production: bool = True):
os.environ["SOLARA_APP"] = "jdaviz.solara"
import solara.server.starlette
import solara.server.settings
solara.server.settings.theme.variant = theme
solara.server.settings.theme.loader = "plain"
solara.server.settings.main.mode = "production" if production else "development"

server = solara.server.starlette.ServerStarlette(host="localhost", port=port)
print(f"Starting server on {server.base_url}")
server.serve_threaded()
server.wait_until_serving()
if browser == "qt":
from . import qt
qt.run_qt(server.base_url)
else:
args += ['--production']
cli(['run', 'jdaviz.solara',
'--theme-loader', 'plain',
'--theme-variant', theme,
'--host', host,
'--port', port] + args)
import webbrowser
controller = webbrowser.get(None if browser == 'default' else browser)
controller.open(server.base_url)
server.join()


def _main(config=None):
Expand All @@ -100,7 +112,7 @@ def _main(config=None):
parser.add_argument('--instrument', type=str, default='nirspec',
help='Manually specifies which instrument parser to use, for Mosviz')
parser.add_argument('--browser', type=str, default='default',
help='Browser to use for application.')
help='Browser to use for application (use qt for embedded Qt browser).')
parser.add_argument('--theme', choices=['light', 'dark'], default='light',
help='Theme to use for application.')
parser.add_argument('--verbosity', choices=_verbosity_levels, default='info',
Expand Down
126 changes: 126 additions & 0 deletions jdaviz/qt.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# this module is based on solara/server/qt.py
import sys
from typing import List
import webbrowser
try:
from qtpy.QtWidgets import QApplication
from qtpy.QtWebEngineWidgets import QWebEngineView
from qtpy.QtWebChannel import QWebChannel
from qtpy import QtCore, QtGui
except ModuleNotFoundError as e:
raise ModuleNotFoundError("""Qt browser requires Qt dependencies, run:
$ pip install jdaviz[qt]
to install.""") from e
import signal
from pathlib import Path

HERE = Path(__file__).parent


# setUrlRequestInterceptor, navigationRequested and acceptNavigationRequest
# all trigger the websocket to disconnect, so we need to block cross origin
# requests on the frontend/browser side by intercepting clicks on links

cross_origin_block_js = """
var script = document.createElement('script');
script.src = 'qrc:///qtwebchannel/qwebchannel.js';
document.head.appendChild(script);
script.onload = function() {
new QWebChannel(qt.webChannelTransport, function(channel) {
let py_callback = channel.objects.py_callback;
document.addEventListener('click', function(event) {
let target = event.target;
while (target && target.tagName !== 'A') {
target = target.parentNode;
}
if (target && target.tagName === 'A') {
const linkOrigin = new URL(target.href).origin;
const currentOrigin = window.location.origin;
if (linkOrigin !== currentOrigin) {
event.preventDefault();
console.log("Blocked cross-origin navigation to:", target.href);
py_callback.open_link(target.href); // Call Python method
}
}
}, true);
});
};
"""


class PyCallback(QtCore.QObject):
@QtCore.Slot(str)
def open_link(self, url):
webbrowser.open(url)


class QWebEngineViewWithPopup(QWebEngineView):
# keep a strong reference to all windows
windows: List = []

def __init__(self):
super().__init__()
self.page().newWindowRequested.connect(self.handle_new_window_request)

# Set up WebChannel and py_callback object
self.py_callback = PyCallback()
self.channel = QWebChannel()
self.channel.registerObject("py_callback", self.py_callback)
self.page().setWebChannel(self.channel)

self.loadFinished.connect(self._inject_javascript)

def _inject_javascript(self, ok):
self.page().runJavaScript(cross_origin_block_js)

def handle_new_window_request(self, info):
webview = QWebEngineViewWithPopup()
geometry = info.requestedGeometry()
width = geometry.width()
parent_size = self.size()
if width == 0:
width = parent_size.width()
height = geometry.height()
if height == 0:
height = parent_size.height()
print("new window", info.requestedUrl(), width, height)
webview.resize(width, height)
webview.setUrl(info.requestedUrl())
webview.show()
QWebEngineViewWithPopup.windows.append(webview)
return webview


def run_qt(url, app_name="Jdaviz"):
app = QApplication([])
web = QWebEngineViewWithPopup()
web.setUrl(QtCore.QUrl(url))
web.resize(1024, 1024)
web.show()

app.setApplicationDisplayName(app_name)
app.setApplicationName(app_name)
web.setWindowTitle(app_name)
app.setWindowIcon(QtGui.QIcon(str(HERE / "data/icons/imviz_icon.svg")))
if sys.platform.startswith("darwin"):
# Set app name, if PyObjC is installed
# Python 2 has PyObjC preinstalled
# Python 3: pip3 install pyobjc-framework-Cocoa
try:
from Foundation import NSBundle

bundle = NSBundle.mainBundle()
if bundle:
app_info = bundle.localizedInfoDictionary() or bundle.infoDictionary()
if app_info is not None:
app_info["CFBundleName"] = app_name
app_info["CFBundleDisplayName"] = app_name
except ModuleNotFoundError:
pass

# without this, ctrl-c does not work in the terminal
signal.signal(signal.SIGINT, signal.SIG_DFL)
app.exec_()
12 changes: 8 additions & 4 deletions jdaviz/solara.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import os
from pathlib import Path
import signal
import threading

import solara
import solara.lab
Expand All @@ -20,15 +20,19 @@

@solara.lab.on_kernel_start
def on_kernel_start():
print("Starting kernel", solara.get_kernel_id())
# at import time, solara runs with a dummy kernel
# we simply ignore that
if "dummy" in solara.get_kernel_id():
return

def on_kernel_close():
# for some reason, sys.exit(0) does not work here
# see https://github.com/encode/uvicorn/discussions/1103
signal.raise_signal(signal.SIGINT)
def exit_process():
# sys.exit(0) does not work, it just throws an exception
# this really makes the process exit
os._exit(0)
# give the kernel some time to close
threading.Thread(target=exit_process).start()
return on_kernel_close


Expand Down
6 changes: 5 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@ roman = [
strauss = [
"strauss",
]
qt = [
"qtpy",
"PySide6"
]

[build-system]
requires = [
Expand Down Expand Up @@ -127,7 +131,7 @@ testpaths = [
astropy_header = true
doctest_plus = "enabled"
text_file_format = "rst"
addopts = "--doctest-rst --import-mode=append"
addopts = "--doctest-rst --import-mode=append --ignore-glob='*/jdaviz/qt.py'"
xfail_strict = true
filterwarnings = [
"error",
Expand Down
3 changes: 3 additions & 0 deletions standalone/hooks/hook-dask.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from PyInstaller.utils.hooks import collect_data_files

datas = collect_data_files('dask')
5 changes: 5 additions & 0 deletions standalone/hooks/hook-matplotlib_inline.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from PyInstaller.utils.hooks import collect_data_files, copy_metadata

datas = collect_data_files('matplotlib_inline')
# since matplotlib 3.9 entry_points.txt is needed
datas += copy_metadata('matplotlib_inline')
17 changes: 16 additions & 1 deletion standalone/jdaviz-cli-entrypoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,24 @@
import matplotlib_inline
import matplotlib_inline.backend_inline

# We still see the above error on CI on jdaviz, and the PyInstaller
# output recommends the following:
import matplotlib
matplotlib.use("module://matplotlib_inline.backend_inline")
# since matplotlib 3.9 (see https://github.com/matplotlib/matplotlib/pull/27948),
# it seems that matplotlib_inline.backend_inline is an alias for inline
# so we make sure to communicate that to PyInstaller
matplotlib.use("inline")

import jdaviz.cli


if __name__ == "__main__":
# should change this to _main, but now it doesn't need arguments
jdaviz.cli.main(layout="")
args = sys.argv.copy()
# change the browser to qt if not specified
if "--browser" not in args:
args.append("--browser")
args.append("qt")
sys.argv = args
jdaviz.cli._main()
2 changes: 1 addition & 1 deletion standalone/jdaviz.spec
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ exe = EXE(
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=True,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
Expand Down
4 changes: 2 additions & 2 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,8 @@ extras =
commands =
jupyter --paths
pip freeze
!cov: pytest --pyargs jdaviz {toxinidir}/docs {posargs}
cov: pytest --pyargs jdaviz {toxinidir}/docs --cov jdaviz --cov-config={toxinidir}/pyproject.toml {posargs}
!cov: pytest --pyargs jdaviz {toxinidir}/docs --ignore=jdaviz/qt.py {posargs}
cov: pytest --pyargs jdaviz {toxinidir}/docs --cov jdaviz --cov-config={toxinidir}/pyproject.toml --ignore=jdaviz/qt.py {posargs}
cov: coverage xml -o {toxinidir}/coverage.xml

pip_pre =
Expand Down

0 comments on commit 87016c7

Please sign in to comment.