diff --git a/.gitignore b/.gitignore index 32204627..b428f831 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,9 @@ server/app/web/templates/index\.html .ipynb_checkpoints *.ipynb +# cefpython +error.log + # misc .DS_Store npm-debug.log diff --git a/server/app/app.py b/server/app/app.py index ff76e425..beb56f12 100644 --- a/server/app/app.py +++ b/server/app/app.py @@ -14,20 +14,25 @@ class Server: def __init__(self): self.data = None self.cache = Cache(config={"CACHE_TYPE": "simple", "CACHE_DEFAULT_TIMEOUT": 860_000}) + self.app = None def create_app(self): - app = Flask(__name__, static_folder="web/static") - app.json_encoder = Float32JSONEncoder - self.cache.init_app(app) - Compress(app) - CORS(app) + self.app = Flask(__name__, static_folder="web/static") + self.app.json_encoder = Float32JSONEncoder + self.cache.init_app(self.app) + Compress(self.app) + CORS(self.app) # Config SECRET_KEY = os.environ.get("CXG_SECRET_KEY", default="SparkleAndShine") - app.config.update(SECRET_KEY=SECRET_KEY) + self.app.config.update(SECRET_KEY=SECRET_KEY) + self.app.config.update(SCRIPTS=[]) resources = get_api_resources() - app.register_blueprint(webapp.bp) - app.register_blueprint(resources.blueprint) - app.add_url_rule("/", endpoint="index") - return app + self.app.register_blueprint(webapp.bp) + self.app.register_blueprint(resources.blueprint) + self.app.add_url_rule("/", endpoint="index") + + def attach_data(self, data, title="Demo"): + self.app.config.update(DATASET_TITLE=title) + self.app.data = data diff --git a/server/cli/launch.py b/server/cli/launch.py index 9dc03977..402fb431 100644 --- a/server/cli/launch.py +++ b/server/cli/launch.py @@ -10,6 +10,7 @@ import click from server.app.app import Server from server.app.util.errors import ScanpyFileError from server.app.util.utils import custom_format_warning +from server.utils.constants import MODES @click.command() @@ -17,7 +18,7 @@ from server.app.util.utils import custom_format_warning @click.option( "--layout", "-l", - type=click.Choice(["umap", "tsne", "draw_graph_fa", "draw_graph_fr", "diffmap", "phate"]), + type=click.Choice(MODES), default="umap", show_default=True, help="Method for layout." @@ -140,9 +141,8 @@ security risk by including the --scripts flag. Make sure you trust the scripts t # Import Flask app server = Server() - app = server.create_app() - app.config.update(DATASET_TITLE=title) - app.config.update(SCRIPTS=scripts) + server.create_app() + server.app.config.update(SCRIPTS=scripts) if not verbose: log = logging.getLogger("werkzeug") @@ -167,7 +167,7 @@ security risk by including the --scripts flag. Make sure you trust the scripts t } try: - app.data = ScanpyEngine(data, args) + server.attach_data(ScanpyEngine(data, args), title=title) except ScanpyFileError as e: raise click.ClickException(f"{e}") @@ -183,4 +183,4 @@ security risk by including the --scripts flag. Make sure you trust the scripts t f = open(devnull, "w") sys.stdout = f - app.run(host=host, debug=debug, port=port, threaded=True) + server.app.run(host=host, debug=debug, port=port, threaded=True) diff --git a/server/gui/__init__.py b/server/gui/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/gui/browser.py b/server/gui/browser.py new file mode 100644 index 00000000..85f8646d --- /dev/null +++ b/server/gui/browser.py @@ -0,0 +1,97 @@ +# flake8: noqa F403, F405 +from cefpython3 import cefpython as cef +from PyQt5.QtCore import * +from PyQt5.QtGui import * +from PyQt5.QtWidgets import * + +from server.gui.utils import WINDOWS, LINUX + +WindowUtils = cef.WindowUtils() + +# OS differences +# noinspection PyUnresolvedReferences +CefWidgetParent = QWidget +if LINUX: + # noinspection PyUnresolvedReferences + CefWidgetParent = QX11EmbedContainer + + +class CefWidget(CefWidgetParent): + def __init__(self, parent=None): + super(CefWidget, self).__init__(parent) + self.parent = parent + self.browser = None + # TODO test without this on linux + self.hidden_window = None # Required for PyQt5 on Linux + self.show() + + def focusInEvent(self, event): + # This event seems to never get called on Linux, as CEF is + # stealing all focus due to Issue #284. + if self.browser: + if WINDOWS: + WindowUtils.OnSetFocus(self.getHandle(), 0, 0, 0) + self.browser.SetFocus(True) + + def focusOutEvent(self, event): + # This event seems to never get called on Linux, as CEF is + # stealing all focus due to Issue #284. + if self.browser: + self.browser.SetFocus(False) + + def embedBrowser(self): + if LINUX: + self.hidden_window = QWindow() + window_info = cef.WindowInfo() + rect = [0, 0, self.width(), self.height()] + window_info.SetAsChild(self.getHandle(), rect) + # TODO better splash + self.browser = cef.CreateBrowserSync(window_info) + + def getHandle(self): + if self.hidden_window: + # PyQt5 on Linux + return int(self.hidden_window.winId()) + else: + return int(self.winId()) + + def moveEvent(self, _): + self.x = 0 + self.y = 0 + if self.browser: + if WINDOWS: + WindowUtils.OnSize(self.getHandle(), 0, 0, 0) + elif LINUX: + self.browser.SetBounds(self.x, self.y, + self.width(), self.height()) + self.browser.NotifyMoveOrResizeStarted() + + def resizeEvent(self, event): + size = event.size() + if self.browser: + if WINDOWS: + WindowUtils.OnSize(self.getHandle(), 0, 0, 0) + elif LINUX: + self.browser.SetBounds(self.x, self.y, + size.width(), size.height()) + self.browser.NotifyMoveOrResizeStarted() + + +class CefApplication(QApplication): + def __init__(self, args): + super(CefApplication, self).__init__(args) + if not cef.GetAppSetting("external_message_pump"): + self.timer = self.createTimer() + + def createTimer(self): + timer = QTimer() + timer.timeout.connect(self.onTimer) + timer.start(10) + return timer + + def onTimer(self): + cef.MessageLoopWork() + + def stopTimer(self): + # Stop the timer after Qt's message loop has ended + self.timer.stop() diff --git a/server/gui/main.py b/server/gui/main.py new file mode 100644 index 00000000..d3f30d85 --- /dev/null +++ b/server/gui/main.py @@ -0,0 +1,212 @@ +# flake8: noqa F403, F405 +import sys +from os.path import splitext, basename + +from cefpython3 import cefpython as cef +from PyQt5.QtCore import * +from PyQt5.QtWidgets import * + +from server.app.app import Server +from server.gui.browser import CefWidget, CefApplication +from server.gui.workers import DataLoadWorker, ServerRunWorker +from server.gui.utils import WINDOWS, LINUX, MAC +from server.utils.constants import MODES + + +# Configuration +# TODO remember this or calculate it? + +WIDTH = 1024 +HEIGHT = 768 + + +# noinspection PyUnresolvedReferences +class MainWindow(QMainWindow): + def __init__(self): + super(MainWindow, self).__init__(None) + self.thread_pool = QThreadPool() + self.cef_widget = None + self.data_widget = None + self.server = Server() + self.server.create_app() + self.runServer() + self.setWindowTitle("cellxgene") + + # Strong focus - accepts focus by tab & click + self.setFocusPolicy(Qt.StrongFocus) + self.setupLayout() + self.setupMenu() + + def setupLayout(self): + self.resize(WIDTH, HEIGHT) + self.cef_widget = CefWidget(self) + self.data_widget = LoadWidget(self) + self.stacked_layout = QStackedLayout() + self.stacked_layout.addWidget(self.data_widget) + self.stacked_layout.addWidget(self.cef_widget) + main_layout = QVBoxLayout() + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.setSpacing(0) + main_layout.addLayout(self.stacked_layout) + frame = QFrame() + frame.setLayout(main_layout) + self.setCentralWidget(frame) + + if WINDOWS: + # On Windows with PyQt5 main window must be shown first + # before CEF browser is embedded, otherwise window is + # not resized and application hangs during resize. + self.show() + + # Browser can be embedded only after layout was set up + self.cef_widget.embedBrowser() + + if LINUX: + # On Linux with PyQt5 the QX11EmbedContainer widget is + # no longer available. An equivalent in Qt5 is to create + # a hidden window, embed CEF browser in it and then + # create a container for that hidden window and replace + # cef widget in the layout with the container. + self.container = QWidget.createWindowContainer( + self.cef_widget.hidden_window, parent=self) + stacked_layout.addWidget(self.container, 1, 0) + + def setupMenu(self): + main_menu = self.menuBar() + file_menu = main_menu.addMenu('File') + load_action = QAction("Load file...", self) + load_action.setStatusTip("Load file") + load_action.setShortcut("Ctrl+O") + load_action.triggered.connect(self.showLoad) + file_menu.addAction(load_action) + + def showLoad(self): + self.stacked_layout.setCurrentIndex(0) + + def closeEvent(self, event): + # Close browser (force=True) and free CEF reference + if self.cef_widget.browser: + self.cef_widget.browser.CloseBrowser(True) + self.clearBrowserReferences() + + def runServer(self): + worker = ServerRunWorker(self.server.app, host="127.0.0.1", port=8000) + self.thread_pool.start(worker) + + def clearBrowserReferences(self): + # Clear browser references that you keep anywhere in your + # code. All references must be cleared for CEF to shutdown cleanly. + self.cef_widget.browser = None + + +class LoadWidget(QFrame): + def __init__(self, parent): + super(LoadWidget, self).__init__(parent=parent) + + # Init layout + self.MAX_CONTENT_WIDTH = 500 + load_ui_layout = QVBoxLayout() + h_margin = (WIDTH - self.MAX_CONTENT_WIDTH) // 2 + if h_margin < 10: + h_margin = 10 + load_ui_layout.setContentsMargins(h_margin, 20, h_margin, 20) + logo_layout = QHBoxLayout() + logo_layout.setContentsMargins(0, 0, 0, 20) + + load_layout = QGridLayout() + load_layout.setContentsMargins(0, 0, 0, 0) + load_layout.setSpacing(0) + message_layout = QHBoxLayout() + message_layout.setContentsMargins(0, 0, 0, 0) + + self.title = "" + self.label = QLabel("cellxgene") + logo_layout.addWidget(self.label) + + # UI section + # TODO add load spinner + # TODO add cancel button to send back to browser (if available) + self.embedding_label = QLabel("embedding: ") + load_layout.addWidget(self.embedding_label, 0, 0) + self.file_label = QLabel("file: ") + load_layout.addWidget(self.file_label, 0, 1) + self.embeddings = QComboBox(self) + self.embeddings.currentIndexChanged.connect(self.updateEmbedding) + self.embeddings.addItems(MODES) + self.embedding_selection = MODES[0] + load_layout.addWidget(self.embeddings, 1, 0) + + self.load = QPushButton("Open...") + self.load.clicked.connect(self.onLoad) + load_layout.addWidget(self.load, 1, 1) + + # Error section + self.error_label = QLabel("") + self.error_label.setWordWrap(True) + self.error_label.setFixedWidth(self.MAX_CONTENT_WIDTH) + message_layout.addWidget(self.error_label, alignment=Qt.AlignTop) + + # Layout + for l in [logo_layout, load_layout, message_layout ]: + load_ui_layout.addLayout(l) + + load_ui_layout.setStretch(2, 10) + self.setLayout(load_ui_layout) + + def updateEmbedding(self, idx): + self.embedding_selection = MODES[idx] + + def onLoad(self): + options = QFileDialog.Options() + # options |= QFileDialog.DontUseNativeDialog + file_name, _ = QFileDialog.getOpenFileName(self, + "Open H5AD File", "", "H5AD Files (*.h5ad)", options=options) + self.title = splitext(basename(file_name))[0] + worker = DataLoadWorker(file_name, self.embedding_selection) + worker.signals.result.connect(self.onDataSuccess) + worker.signals.error.connect(self.onDataError) + self.window().thread_pool.start(worker) + + def onDataSuccess(self, data): + self.window().server.attach_data(data, self.title) + self.navigateToLocation() + # Reveal browser + self.window().stacked_layout.setCurrentIndex(1) + + def onDataError(self, err): + self.error_label.setText(f"Error: {err}") + self.error_label.resize(self.MAX_CONTENT_WIDTH, self.error_label.height()) + + def navigateToLocation(self, location="http://localhost:8000/"): + self.window().cef_widget.browser.Navigate(location) + + +def main(): + # This generates an error.log file on error + sys.excepthook = cef.ExceptHook # To shutdown all CEF processes on error + settings = {} + # Instead of timer loop + if MAC: + settings["external_message_pump"] = True + + # Create and launch cef browser and qt window + cef.Initialize(settings) + app = CefApplication(sys.argv) + main_window = MainWindow() + main_window.show() + main_window.activateWindow() + main_window.raise_() + app.exec_() + + # Clean up on close + if not cef.GetAppSetting("external_message_pump"): + app.stopTimer() + # TODO clean up threads when we switch threading model + del main_window # Just to be safe, similarly to "del app" + del app # Must destroy app object before calling Shutdown + cef.Shutdown() + sys.exit(0) + + +if __name__ == '__main__': + main() diff --git a/server/gui/utils.py b/server/gui/utils.py new file mode 100644 index 00000000..8b8063e7 --- /dev/null +++ b/server/gui/utils.py @@ -0,0 +1,21 @@ +import platform + +from PyQt5.QtCore import QObject, pyqtSignal + +# Detect OS +WINDOWS = (platform.system() == "Windows") +LINUX = (platform.system() == "Linux") +MAC = (platform.system() == "Darwin") + + +class WorkerSignals(QObject): + """ + Defines the signals available from a running worker thread. + Supported signals are: + finished + error - `str` error message + result - `object` data returned from processing, anything + """ + finished = pyqtSignal() + error = pyqtSignal(str) + result = pyqtSignal(object) diff --git a/server/gui/workers.py b/server/gui/workers.py new file mode 100644 index 00000000..4792468f --- /dev/null +++ b/server/gui/workers.py @@ -0,0 +1,51 @@ +import traceback + +from PyQt5.QtCore import QRunnable, pyqtSlot + +from server.gui.utils import WorkerSignals + + +class DataLoadWorker(QRunnable): + def __init__(self, data_file, layout="umap", *args, **kwargs): + super(DataLoadWorker, self).__init__() + self.data_file = data_file + self.layout = layout + self.signals = WorkerSignals() + + @pyqtSlot() + def run(self): + if not self.data_file: + self.signals.finished.emit() + return + + # delayed import to speed load + from server.app.scanpy_engine.scanpy_engine import ScanpyEngine + args = { + "layout": self.layout, + "diffexp": "ttest", + "max_category_items": 100, + "diffexp_lfc_cutoff": 0.01, + "obs_names": None, + "var_names": None, + } + try: + data_results = ScanpyEngine(self.data_file, args) + except Exception as e: + traceback.print_exc() + self.signals.error.emit(str(e)) + else: + self.signals.result.emit(data_results) + finally: + self.signals.finished.emit() + + +class ServerRunWorker(QRunnable): + def __init__(self, app, host, port, *args, **kwargs): + super(ServerRunWorker, self).__init__() + self.app = app + self.host = host + self.port = port + + @pyqtSlot() + def run(self): + self.app.run(host=self.host, debug=False, port=self.port, threaded=True) diff --git a/server/utils/constants.py b/server/utils/constants.py new file mode 100644 index 00000000..48f05329 --- /dev/null +++ b/server/utils/constants.py @@ -0,0 +1 @@ +MODES = ["umap", "tsne", "draw_graph_fa", "draw_graph_fr", "diffmap", "phate"] diff --git a/setup.py b/setup.py index cee6e97e..e7d8196b 100644 --- a/setup.py +++ b/setup.py @@ -36,5 +36,5 @@ setup( "Topic :: Scientific/Engineering :: Bio-Informatics", ], entry_points={"console_scripts": ["cellxgene = server.cli.cli:cli"]}, - extras_require=dict(louvain=["python-igraph", "louvain>=0.6"]), + extras_require=dict(louvain=["python-igraph", "louvain>=0.6"], gui=["PyQt5>=5.12.1", "cefpython3>=66"]), )