Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: use embedded qt browser for jdaviz standalone #3188

Merged
merged 7 commits into from
Jan 14, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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()
maartenbreddels marked this conversation as resolved.
Show resolved Hide resolved


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")
kecnry marked this conversation as resolved.
Show resolved Hide resolved
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
Loading