diff --git a/.travis.yml b/.travis.yml index 3240ea16..cdbfc574 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,7 +13,7 @@ env: global: - SETUP_XVFB=True - CONDA_CHANNELS="astrofrog/label/dev" - - CONDA_DEPENDENCIES="astropy qtpy traitlets ipywidgets>=7.0 ipyevents widgetsnbextension pyqt pytest requests nomkl matplotlib beautifulsoup4 lxml jupyterlab" + - CONDA_DEPENDENCIES="astropy qtpy traitlets ipywidgets>=7.0 ipyevents widgetsnbextension pyqt pytest requests nomkl matplotlib beautifulsoup4 lxml jupyterlab flask flask-cors" - PIP_DEPENDENCIES="sphinx-automodapi numpydoc sphinx_rtd_theme pytest-faulthandler" matrix: - PYTHON_VERSION=2.7 diff --git a/CHANGES.rst b/CHANGES.rst index dbeb654d..a984db19 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,8 @@ 0.4.0 (unreleased) ------------------ +- Added ``load_fits_data`` method for Qt and Jupyter clients. [#78] + - Fix compatibility with Jupyter Lab. [#63, #65] - Added GUI controls for imagery layers. [#64] diff --git a/appveyor.yml b/appveyor.yml index da66f2db..a064dd08 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -12,7 +12,7 @@ environment: # to the matrix section. CONDA_CHANNELS: "astrofrog/label/dev conda-forge" - CONDA_DEPENDENCIES: "astropy qtpy traitlets ipywidgets>=7.0 ipyevents widgetsnbextension pyqt pytest requests matplotlib" + CONDA_DEPENDENCIES: "astropy qtpy traitlets ipywidgets>=7.0 ipyevents widgetsnbextension pyqt pytest requests matplotlib flask flask-cors" PIP_DEPENDENCIES: "pytest-faulthandler" matrix: diff --git a/lib/wwt.js b/lib/wwt.js index 14680084..7841d6eb 100644 --- a/lib/wwt.js +++ b/lib/wwt.js @@ -24,19 +24,9 @@ var WWTView = widgets.DOMWidgetView.extend({ // page. We use the same HTML file as for the Qt client. var div = document.createElement("div"); - try { - // This should work for Jupyter notebook - nbextensions = requirejs.s.contexts._.config.paths.nbextensions; - } catch(e) { - // We should get here in Jupyter lab - nbextensions = null; - } + this.wwt_base_url = require('@jupyterlab/coreutils').PageConfig.getBaseUrl(); - if (nbextensions == null) { - div.innerHTML = "" - } else { - div.innerHTML = "" - } + div.innerHTML = "" this.el.appendChild(div); @@ -122,6 +112,10 @@ var WWTView = widgets.DOMWidgetView.extend({ } } + if (msg['url'] != null && msg['url'].slice(4) == '/wwt') { + msg['url'] = this.wwt_base_url + msg['url']; + } + this.wwt_window.wwt_apply_json_message(this.wwt_window.wwt, msg); } diff --git a/pywwt/core.py b/pywwt/core.py index fcb9fd1e..d224ee2b 100644 --- a/pywwt/core.py +++ b/pywwt/core.py @@ -1,3 +1,4 @@ +import os from traitlets import HasTraits, observe, validate, TraitError from astropy import units as u from astropy.time import Time @@ -266,8 +267,8 @@ def _validate_scale(self, proposal): def track_object(self, obj): """ - Focus the viewer on a particular object while in solar system mode. - Available objects include the Sun, the planets, the Moon, Jupiter's + Focus the viewer on a particular object while in solar system mode. + Available objects include the Sun, the planets, the Moon, Jupiter's Galilean moons, and Pluto. Parameters @@ -308,7 +309,7 @@ def set_view(self, mode): 'panorama'] ss_levels = ['solar_system', 'milky_way', 'universe'] ss_mode = '3D Solar System View' - + if mode in available: self._send_msg(event='set_viewer_mode', mode=mode) if mode == 'sky' or mode == 'panorama': @@ -320,12 +321,12 @@ def set_view(self, mode): self.current_mode = mode else: raise ValueError('the given mode does not exist') - + self.reset_view() def reset_view(self): """ - Reset the current view mode's coordinates and field of view to + Reset the current view mode's coordinates and field of view to their original states. """ if self.current_mode == 'sky': @@ -345,7 +346,7 @@ def reset_view(self): fov=1e14*u.deg, instant=False) if self.current_mode == 'panorama': pass - + def load_image_collection(self, url): """ Load a collection of layers for possible use in the viewer. @@ -489,3 +490,12 @@ def add_collection(self, points, **kwargs): """ collection = CircleCollection(self, points, **kwargs) return collection + + def _validate_fits_data(self, filename): + if not os.path.exists(filename): + raise Exception("File {0} does not exist".format(filename)) + from astropy.wcs import WCS + wcs = WCS(filename) + projection = wcs.celestial.wcs.ctype[0][4:] + if projection != '-TAN': + raise ValueError("Only -TAN FITS files are supported at the moment") diff --git a/pywwt/data_server.py b/pywwt/data_server.py new file mode 100644 index 00000000..a13d0b5a --- /dev/null +++ b/pywwt/data_server.py @@ -0,0 +1,88 @@ +import os +import time +import socket +import logging +from hashlib import md5 +from threading import Thread + +__all__ = ['get_data_server'] + + +def get_data_server(verbose=True): + """ + This starts up a flask server and returns a handle to a DataServer + object which can be used to register files to serve. + """ + + from flask import Flask + from flask_cors import CORS + + class FlaskWrapper(Flask): + + port = None + host = None + + def run(self, *args, **kwargs): + self.host = kwargs.get('host', None) + self.port = kwargs.get('port', None) + try: + super(FlaskWrapper, self).run(*args, **kwargs) + finally: + self.host = None + self.port = None + + app = FlaskWrapper('DataServer') + CORS(app) + if verbose: + log = logging.getLogger('werkzeug') + log.setLevel(logging.ERROR) + + class DataServer(object): + + def __init__(self): + self._files = {} + self._thread = Thread(target=self.start_app) + self._thread.daemon = True + self._thread.start() + self._app = app + while self._app.host is None and self._app.port is None: + time.sleep(0.1) + + @property + def port(self): + return self._app.port + + @property + def host(self): + return self._app.host + + def start_app(self): + host = socket.gethostbyname('localhost') + for port in range(8000, 9000): + try: + return app.run(host=host, port=port) + except Exception: + pass + raise Exception("Could not start up data server") + + def serve_file(self, filename, real_name=True, extension=''): + with open(filename, 'rb') as f: + content = f.read() + if real_name: + hash = os.path.basename(filename) + else: + hash = md5(content).hexdigest() + extension + self._files[hash] = os.path.abspath(filename) + return 'http://' + self.host + ':' + str(self.port) + '/data/' + hash + + def get_file_contents(self, hash): + with open(self._files[hash], 'rb') as f: + return f.read() + + ds = DataServer() + + @app.route("/data/") + def data(hash): + return ds.get_file_contents(hash) + + return ds diff --git a/pywwt/jupyter.py b/pywwt/jupyter.py index 5dcf5111..fe6d2f80 100644 --- a/pywwt/jupyter.py +++ b/pywwt/jupyter.py @@ -13,6 +13,7 @@ from ipyevents import Event as DOMListener from .core import BaseWWTWidget +from .jupyter_server import serve_file __all__ = ['WWTJupyterWidget'] @@ -49,6 +50,19 @@ def _default_layout(self): def _send_msg(self, **kwargs): self.send(kwargs) + def load_fits_data(self, filename): + """ + Load a FITS file. + + Paramters + --------- + filename : str + The filename of the FITS file to display. + """ + self._validate_fits_data(filename) + url = serve_file(filename, extension='.fits') + self._send_msg(event='load_fits', url=url) + @property def layer_controls(self): if self._controls is None: diff --git a/pywwt/jupyter_server.py b/pywwt/jupyter_server.py index 75ae94a6..3722f9c7 100644 --- a/pywwt/jupyter_server.py +++ b/pywwt/jupyter_server.py @@ -1,4 +1,7 @@ import os +import json +from hashlib import md5 +from tornado import web from notebook.utils import url_path_join from notebook.base.handlers import IPythonHandler @@ -6,29 +9,65 @@ STATIC_DIR = os.path.join(os.path.dirname(__file__), 'nbextension', 'static') +CONFIG = os.path.expanduser('~/.pywwt') -class WWTHTMLHandler(IPythonHandler): - def get(self): - with open(os.path.join(STATIC_DIR, 'wwt.html')) as f: - content = f.read() - self.finish(content) +class WWTFileHandler(IPythonHandler): + + def get(self, filename): + + filename = os.path.basename(filename) + # First we check if this is a standard file in the static directory + if os.path.exists(os.path.join(STATIC_DIR, filename)): + path = os.path.join(STATIC_DIR, filename) + else: + # If not, we open the config file which should contain a JSON + # dictionary with filenames and paths. + if not os.path.exists(CONFIG): + raise web.HTTPError(404) + with open(CONFIG) as f: + config = json.load(f) + if filename in config['paths']: + path = config['paths'][filename] + else: + raise web.HTTPError(404) -class WWTJSHandler(IPythonHandler): - def get(self): - with open(os.path.join(STATIC_DIR, 'wwt_json_api.js')) as f: + with open(path, 'rb') as f: content = f.read() + self.finish(content) +def serve_file(path, extension=''): + + if not os.path.exists(path): + raise ValueError("Path {0} does not exist".format(path)) + + hash = md5(path.encode('utf-8')).hexdigest() + extension + + with open(CONFIG) as f: + config = json.load(f) + + if hash not in config['paths']: + + config['paths'][hash] = os.path.abspath(path) + + with open(CONFIG, 'w') as f: + json.dump(config, f) + + return '/wwt/' + hash + + def load_jupyter_server_extension(nb_server_app): web_app = nb_server_app.web_app host_pattern = '.*$' - route_pattern = url_path_join(web_app.settings['base_url'], '/wwt.html') - web_app.add_handlers(host_pattern, [(route_pattern, WWTHTMLHandler)]) + if not os.path.exists(CONFIG): + config = {'paths': {}} + with open(CONFIG, 'w') as f: + json.dump(config, f) - route_pattern = url_path_join(web_app.settings['base_url'], '/wwt_json_api.js') - web_app.add_handlers(host_pattern, [(route_pattern, WWTJSHandler)]) + route_pattern = url_path_join(web_app.settings['base_url'], '/wwt/(.*)') + web_app.add_handlers(host_pattern, [(route_pattern, WWTFileHandler)]) diff --git a/pywwt/nbextension/static/wwt_json_api.js b/pywwt/nbextension/static/wwt_json_api.js index 9fd4648b..8ccd527f 100644 --- a/pywwt/nbextension/static/wwt_json_api.js +++ b/pywwt/nbextension/static/wwt_json_api.js @@ -55,6 +55,10 @@ function wwt_apply_json_message(wwt, msg) { wwt.gotoRaDecZoom(msg['ra'], msg['dec'], msg['fov'], msg['instant']); break; + case 'load_fits': + wwt.loadFits(msg['url']); + break; + case 'setting_set': var name = msg['setting']; wwt.settings["set_" + name](msg['value']); diff --git a/pywwt/qt.py b/pywwt/qt.py index dd789fb1..2be58131 100644 --- a/pywwt/qt.py +++ b/pywwt/qt.py @@ -13,6 +13,7 @@ from .core import BaseWWTWidget from .logger import logger +from .data_server import get_data_server __all__ = ['WWTQtClient'] @@ -96,7 +97,7 @@ def _check_ready(self): class WWTQtWidget(QtWidgets.QWidget): - def __init__(self, parent=None): + def __init__(self, url=None, parent=None): super(WWTQtWidget, self).__init__(parent=parent) @@ -104,7 +105,7 @@ def __init__(self, parent=None): self.page = WWTQWebEnginePage() self.page.setView(self.web) self.web.setPage(self.page) - self.web.setHtml(WWT_HTML) + self.web.setUrl(QtCore.QUrl(url)) layout = QtWidgets.QVBoxLayout() layout.setContentsMargins(0, 0, 0, 0) @@ -162,7 +163,11 @@ def __init__(self, block_until_ready=False, size=None): if app is None: app = QtWidgets.QApplication(['']) - self.widget = WWTQtWidget() + self._data_server = get_data_server() + self._data_server.serve_file(WWT_JSON_FILE, real_name=True) + wwt_url = self._data_server.serve_file(WWT_HTML_FILE, real_name=True) + + self.widget = WWTQtWidget(url=wwt_url) if size is not None: self.widget.resize(*size) self.widget.show() @@ -186,6 +191,19 @@ def _send_msg(self, async=True, **kwargs): msg = json.dumps(kwargs) return self.widget._run_js("wwt_apply_json_message(wwt, {0});".format(msg), async=async) + def load_fits_data(self, filename): + """ + Load a FITS file. + + Paramters + --------- + filename : str + The filename of the FITS file to display. + """ + self._validate_fits_data(filename) + url = self._data_server.serve_file(filename, extension='.fits') + self._send_msg(event='load_fits', url=url) + def render(self, filename): """ Saves a screenshot of the viewer's current state. diff --git a/setup.py b/setup.py index 7dc28766..c1adb56a 100755 --- a/setup.py +++ b/setup.py @@ -102,7 +102,9 @@ 'ipywidgets>=7.0.0', 'ipyevents', 'traitlets', - 'qtpy' + 'qtpy', + 'flask', + 'flask-cors' ], extras_require = { 'test': [