diff --git a/.github/workflows/standalone.yml b/.github/workflows/standalone.yml index c6dc6a827f..57d7fedddc 100644 --- a/.github/workflows/standalone.yml +++ b/.github/workflows/standalone.yml @@ -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: @@ -31,11 +31,28 @@ 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: @@ -43,7 +60,7 @@ jobs: 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) @@ -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) diff --git a/jdaviz/cli.py b/jdaviz/cli.py index 669a3d3c3b..8ed0c1b580 100644 --- a/jdaviz/cli.py +++ b/jdaviz/cli.py @@ -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 @@ -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): @@ -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', diff --git a/jdaviz/qt.py b/jdaviz/qt.py new file mode 100644 index 0000000000..1d2616b25a --- /dev/null +++ b/jdaviz/qt.py @@ -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_() diff --git a/jdaviz/solara.py b/jdaviz/solara.py index 713e6d9494..c5679c5a46 100644 --- a/jdaviz/solara.py +++ b/jdaviz/solara.py @@ -1,6 +1,6 @@ import os from pathlib import Path -import signal +import threading import solara import solara.lab @@ -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 diff --git a/pyproject.toml b/pyproject.toml index f03840a362..3efff8bc5a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -85,6 +85,10 @@ roman = [ strauss = [ "strauss", ] +qt = [ + "qtpy", + "PySide6" +] [build-system] requires = [ @@ -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", diff --git a/standalone/hooks/hook-dask.py b/standalone/hooks/hook-dask.py new file mode 100644 index 0000000000..e2db860d1b --- /dev/null +++ b/standalone/hooks/hook-dask.py @@ -0,0 +1,3 @@ +from PyInstaller.utils.hooks import collect_data_files + +datas = collect_data_files('dask') diff --git a/standalone/hooks/hook-matplotlib_inline.py b/standalone/hooks/hook-matplotlib_inline.py new file mode 100644 index 0000000000..432d0d302b --- /dev/null +++ b/standalone/hooks/hook-matplotlib_inline.py @@ -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') diff --git a/standalone/jdaviz-cli-entrypoint.py b/standalone/jdaviz-cli-entrypoint.py index 5fbcbf7f59..05c29281ad 100644 --- a/standalone/jdaviz-cli-entrypoint.py +++ b/standalone/jdaviz-cli-entrypoint.py @@ -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() diff --git a/standalone/jdaviz.spec b/standalone/jdaviz.spec index 11ddf9a610..83ff977079 100644 --- a/standalone/jdaviz.spec +++ b/standalone/jdaviz.spec @@ -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, diff --git a/tox.ini b/tox.ini index e33e4b5c33..300bf7959e 100644 --- a/tox.ini +++ b/tox.ini @@ -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 =