mirror of
https://github.com/chanzuckerberg/cellxgene.git
synced 2026-09-15 20:57:56 +08:00
* Fix Makefile whitespace and .PHONY use
* Fix Makefile filename
* Modularize Makefile into client and server Makefiles
Part of the reason that the Makefile in the root directory is a bit
complicated is that it tries to handle tasks that can be handled
separately in the client and server modules.
This commit pushes some of the make logic specific to each module into
their own makefiles and calls out to those makefiles from that in the
project root.
* Add auto-formatting to client and server modules
One thing that can make linting faster is auto-formatting. This commit
adds the yapf auto-formatting tool to the server module and uses
eslint's "fix" functionality to speed up the linting/formatting process.
* Add yapf for automatic code formatting
* Add a root test target that calls sub-tests
* Apply yapf to python files
* Do not duplicate npm commands, simply pass through
* Update documentation
* Do not shadow reserved word len
* Add general test target
* Fix make call in dev-env
* Use black instead of yapf
* Run flake8 from the root directory
* Revert "Apply yapf to python files"
This reverts commit cdca128a01.
* Apply black to python code
* Resolve lint errors resulting from black format
* Add explanation of server unit tests in dev guidelines
402 lines
14 KiB
Python
402 lines
14 KiB
Python
# flake8: noqa F403, F405
|
|
from functools import partialmethod
|
|
from multiprocessing import Pipe, Process, freeze_support
|
|
from os import environ
|
|
from os.path import splitext, basename, dirname, join
|
|
import sys
|
|
import threading
|
|
|
|
from cefpython3 import cefpython as cef
|
|
import PySide2
|
|
from PySide2.QtGui import *
|
|
from PySide2.QtCore import *
|
|
from PySide2.QtWidgets import *
|
|
|
|
import server.gui.cellxgene_rc
|
|
from server.gui.browser import CefWidget, CefApplication
|
|
from server.gui.workers import Worker, SiteReadyWorker
|
|
from server.gui.utils import WINDOWS, LINUX, MAC, FileLoadSignals, Emitter, WorkerSignals, FileChanged
|
|
from server.utils.utils import find_available_port
|
|
|
|
if WINDOWS or LINUX:
|
|
dirname = dirname(PySide2.__file__)
|
|
plugin_path = join(dirname, "plugins", "platforms")
|
|
environ["QT_QPA_PLATFORM_PLUGIN_PATH"] = plugin_path
|
|
|
|
# Configuration
|
|
# TODO remember this or calculate it?
|
|
WIDTH = 1300
|
|
HEIGHT = 800
|
|
MAX_CONTENT_WIDTH = 700
|
|
GUI_PORT = find_available_port("localhost")
|
|
BROWSER_INDEX = 0
|
|
LOAD_INDEX = 1
|
|
|
|
|
|
class MainWindow(QMainWindow):
|
|
def __init__(self):
|
|
super(MainWindow, self).__init__(None)
|
|
self.cef_widget = None
|
|
self.data_widget = None
|
|
self.stacked_layout = None
|
|
self.parent_conn, self.child_conn = None, None
|
|
self.load_emitter = None
|
|
self.emitter_thread = None
|
|
self.worker = None
|
|
self.url = f"http://localhost:{GUI_PORT}/"
|
|
self.setWindowTitle("cellxgene")
|
|
|
|
# Strong focus - accepts focus by tab & click
|
|
self.setFocusPolicy(Qt.StrongFocus)
|
|
self.setupLayout()
|
|
self.setupMenu()
|
|
|
|
def showBrowser(self):
|
|
self.stacked_layout.setCurrentIndex(BROWSER_INDEX)
|
|
|
|
def restartOnError(self):
|
|
self.window().shutdownServer()
|
|
# close emitter on error/finished
|
|
self.parent_conn, self.child_conn = Pipe()
|
|
self.load_emitter = Emitter(self.parent_conn, WorkerSignals)
|
|
self.emitter_thread = threading.Thread(target=self.load_emitter.run, daemon=True)
|
|
self.emitter_thread.start()
|
|
# send to load with error message?
|
|
|
|
def setupLayout(self):
|
|
self.resize(WIDTH, HEIGHT)
|
|
self.cef_widget = CefWidget(self)
|
|
self.cef_widget.setSizePolicy(QSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding))
|
|
self.data_widget = LoadWidget(self)
|
|
self.stacked_layout = QStackedLayout()
|
|
self.stacked_layout.addWidget(self.cef_widget)
|
|
self.stacked_layout.addWidget(self.data_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)
|
|
self.stacked_layout.replaceWidget(self.cef_widget, self.container)
|
|
self.stacked_layout.setCurrentIndex(LOAD_INDEX)
|
|
|
|
def setupServer(self):
|
|
self.shutdownServer()
|
|
# close emitter on error/finished
|
|
self.parent_conn, self.child_conn = Pipe()
|
|
self.load_emitter = Emitter(self.parent_conn, WorkerSignals)
|
|
self.emitter_thread = threading.Thread(target=self.load_emitter.run, daemon=True)
|
|
self.emitter_thread.start()
|
|
# send to load with error message?
|
|
|
|
def shutdownServer(self):
|
|
if self.worker:
|
|
self.worker.terminate()
|
|
if self.parent_conn:
|
|
self.parent_conn.close()
|
|
|
|
def setupMenu(self):
|
|
# TODO add communication to subprocess on reload
|
|
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.clearMessages()
|
|
self.stacked_layout.setCurrentIndex(LOAD_INDEX)
|
|
|
|
def clearMessages(self):
|
|
self.data_widget.reset()
|
|
|
|
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 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
|
|
load_ui_layout = QVBoxLayout()
|
|
h_margin = (WIDTH - 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)
|
|
|
|
file_layout = QVBoxLayout()
|
|
|
|
message_layout = QHBoxLayout()
|
|
message_layout.setContentsMargins(0, 0, 0, 0)
|
|
|
|
self.serverError = False
|
|
self.file_name = FilePath()
|
|
|
|
self.label = QLabel()
|
|
logo = QPixmap(":/logo.png")
|
|
self.label.setPixmap(logo)
|
|
self.label.setContentsMargins(100, 0, 100, 0)
|
|
logo_layout.addWidget(self.label)
|
|
|
|
# UI section
|
|
# TODO add cancel button to send back to browser (if available)
|
|
|
|
self.file_area = FileArea(self)
|
|
self.file_name.signals.changed.connect(self.updatePath)
|
|
|
|
self.launch_widget = QLabel("Select a file to launch cellxgene")
|
|
# self.launch_widget.setEnabled(False)
|
|
# self.launch_widget.clicked.connect(self.onLoad)
|
|
|
|
self.progress = QProgressBar()
|
|
self.progress.setTextVisible(False)
|
|
|
|
file_layout.addWidget(self.file_area)
|
|
self.loading_layout = QStackedLayout()
|
|
self.loading_layout.addWidget(self.launch_widget)
|
|
self.loading_layout.addWidget(self.progress)
|
|
file_layout.addLayout(self.loading_layout)
|
|
file_layout.setStretch(0, 10)
|
|
|
|
# Error section
|
|
self.error_label = QLabel("")
|
|
self.error_label.setWordWrap(True)
|
|
self.error_label.setFixedWidth(MAX_CONTENT_WIDTH)
|
|
message_layout.addWidget(self.error_label)
|
|
|
|
# Options Form
|
|
|
|
# Layout
|
|
for l in [logo_layout, file_layout, message_layout]:
|
|
load_ui_layout.addLayout(l)
|
|
|
|
# TODO remove magic number
|
|
load_ui_layout.setStretch(1, 10)
|
|
self.setLayout(load_ui_layout)
|
|
|
|
self.timer = QTimer()
|
|
self.timer.setInterval(100)
|
|
self.timer.timeout.connect(self.updateProgress)
|
|
|
|
self.signals = FileLoadSignals()
|
|
self.signals.selectedFile.connect(self.createScanpyEngine)
|
|
self.signals.error.connect(self.onError)
|
|
|
|
def updatePath(self):
|
|
file_name = self.file_name.value
|
|
if file_name:
|
|
self.file_area.label.setText("File: " + file_name)
|
|
else:
|
|
self.file_area.label.setText("")
|
|
# self.launch_widget.setEnabled(bool(file_name))
|
|
|
|
def reset(self):
|
|
self.loading_layout.setCurrentIndex(0)
|
|
self.timer.stop()
|
|
self.error_label.setText("")
|
|
self.file_name.updateValue(None)
|
|
|
|
def updateProgress(self):
|
|
curr_val = self.progress.value()
|
|
next_val = (curr_val + 1) % 100
|
|
self.progress.setValue(next_val)
|
|
|
|
def resetProgress(self):
|
|
self.progress.setValue(0)
|
|
self.loading_layout.setCurrentIndex(0)
|
|
self.timer.stop()
|
|
|
|
def createScanpyEngine(self, file_name):
|
|
title = splitext(basename(file_name))[0]
|
|
self.window().setupServer()
|
|
worker = Worker(
|
|
self.window().parent_conn,
|
|
self.window().child_conn,
|
|
file_name,
|
|
host="127.0.0.1",
|
|
port=GUI_PORT,
|
|
title=title,
|
|
engine_options={},
|
|
)
|
|
self.window().load_emitter.signals.ready.connect(self.onDataReady)
|
|
self.window().load_emitter.signals.engine_error.connect(self.onServerError)
|
|
self.window().load_emitter.signals.server_error.connect(self.onServerError)
|
|
# Error is generic error from emitter
|
|
self.window().load_emitter.signals.error.connect(self.onServerError)
|
|
self.window().worker = Process(target=worker.run, daemon=True)
|
|
self.window().worker.start()
|
|
self.window().child_conn.close()
|
|
|
|
def onLoad(self):
|
|
if self.file_name.value:
|
|
# Reset error on reload
|
|
self.serverError = False
|
|
self.loading_layout.setCurrentIndex(1)
|
|
self.timer.start()
|
|
self.signals.selectedFile.emit(self.file_name.value)
|
|
else:
|
|
self.signals.error.emit("Please select a file before launching.")
|
|
|
|
def onDataReady(self):
|
|
self.site_ready_worker = SiteReadyWorker(self.window().url)
|
|
self.site_ready_worker.signals.ready.connect(self.onServerReady)
|
|
self.site_ready_worker.signals.error.connect(self.onServerError)
|
|
|
|
srw_thread = threading.Thread(target=self.site_ready_worker.run, daemon=True)
|
|
srw_thread.start()
|
|
|
|
def onServerReady(self):
|
|
if not self.serverError:
|
|
self.resetProgress()
|
|
self.window().cef_widget.browser.Navigate(self.window().url)
|
|
self.window().showBrowser()
|
|
|
|
def onError(self, err, server_error=False):
|
|
# Restart worker
|
|
if server_error:
|
|
self.serverError = True
|
|
# Report error and switch to load screen
|
|
self.window().shutdownServer()
|
|
self.resetProgress()
|
|
self.window().stacked_layout.setCurrentIndex(LOAD_INDEX)
|
|
self.error_label.setText(f"Error: {err}")
|
|
self.error_label.resize(MAX_CONTENT_WIDTH, self.error_label.height())
|
|
self.window().repaint()
|
|
|
|
onServerError = partialmethod(onError, server_error=True)
|
|
|
|
|
|
class FilePath(QObject):
|
|
def __init__(self):
|
|
super(FilePath, self).__init__()
|
|
self.value = ""
|
|
self.signals = FileChanged()
|
|
|
|
def updateValue(self, path=None):
|
|
self.value = path
|
|
self.signals.changed.emit(self.value != path)
|
|
|
|
|
|
class FileArea(QFrame):
|
|
def __init__(self, parent):
|
|
super(FileArea, self).__init__()
|
|
self.setFrameShape(QFrame.Box)
|
|
self.setMinimumHeight(100)
|
|
self.setFixedWidth(MAX_CONTENT_WIDTH)
|
|
self.setAcceptDrops(True)
|
|
self.instructions = QLabel(self)
|
|
self.instructions.setText("Drag & Drop a h5ad file to load or open")
|
|
self.instructions.setGeometry(10, 10, MAX_CONTENT_WIDTH, self.instructions.height())
|
|
self.loadButton = QPushButton("Open...", parent=self)
|
|
x_pos = (MAX_CONTENT_WIDTH - self.loadButton.width()) / 2
|
|
self.loadButton.setGeometry(x_pos, 50, self.loadButton.width(), self.loadButton.height())
|
|
self.loadButton.clicked.connect(self.fileBrowse)
|
|
self.label = QLabel(self)
|
|
self.label.setGeometry(10, 75, MAX_CONTENT_WIDTH, self.label.height())
|
|
|
|
def fileBrowse(self):
|
|
options = QFileDialog.Options()
|
|
# options |= QFileDialog.DontUseNativeDialog
|
|
file_name, _ = QFileDialog.getOpenFileName(self, "Open H5AD File", "", "H5AD Files (*.h5ad)", options=options)
|
|
if file_name:
|
|
self.parent().file_name.updateValue(file_name)
|
|
self.parent().onLoad()
|
|
|
|
def dragEnterEvent(self, e):
|
|
if e.mimeData().hasUrls:
|
|
e.accept()
|
|
else:
|
|
e.ignore()
|
|
|
|
def dragMoveEvent(self, e):
|
|
if e.mimeData().hasUrls:
|
|
e.accept()
|
|
else:
|
|
e.ignore()
|
|
|
|
def dropEvent(self, e):
|
|
"""
|
|
Drop files directly onto the widget
|
|
File locations are stored in fname
|
|
:param e:
|
|
:return:
|
|
"""
|
|
if e.mimeData().hasUrls:
|
|
e.setDropAction(Qt.CopyAction)
|
|
e.accept()
|
|
for url in e.mimeData().urls():
|
|
file_name = str(url.toLocalFile())
|
|
self.parent().file_name.updateValue(file_name)
|
|
self.parent().onLoad()
|
|
else:
|
|
e.ignore()
|
|
|
|
|
|
def main():
|
|
freeze_support()
|
|
# 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.setWindowTitle("cellxgene")
|
|
main_window.setUnifiedTitleAndToolBarOnMac(True)
|
|
main_window.setWindowIcon(QIcon(":icon.png"))
|
|
main_window.show()
|
|
main_window.activateWindow()
|
|
main_window.raise_()
|
|
try:
|
|
app.exec_()
|
|
except Exception as e:
|
|
raise
|
|
finally:
|
|
# Clean up on close
|
|
if not cef.GetAppSetting("external_message_pump"):
|
|
app.stopTimer()
|
|
|
|
main_window.shutdownServer()
|
|
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()
|