diff --git a/makefile b/makefile index 1e0b1514..388d308e 100644 --- a/makefile +++ b/makefile @@ -130,3 +130,10 @@ uninstall : pip uninstall -y cellxgene || : .PHONY : install install-dev install-release-test install-release uninstall + +# GUI + +build-assets : + pyside2-rcc server/gui/cellxgene.qrc -o server/gui/cellxgene_rc.py + +.PHONY : build-assets diff --git a/server/cli/launch.py b/server/cli/launch.py index 9ebcb869..987565a2 100644 --- a/server/cli/launch.py +++ b/server/cli/launch.py @@ -1,4 +1,5 @@ import errno +import functools import logging from os import devnull from os.path import splitext, basename, getsize @@ -13,22 +14,56 @@ from server.app.util.errors import ScanpyFileError from server.app.util.utils import custom_format_warning from server.utils.utils import find_available_port, is_port_available - # anything bigger than this will generate a special message -BIG_FILE_SIZE_THRESHOLD = 100 * 2**20 # 100MB +BIG_FILE_SIZE_THRESHOLD = 100 * 2 ** 20 # 100MB + + +def common_args(func): + """ + Decorator to contain CLI args that will be common to both CLI and GUI: title and engine args. + """ + @click.option("--title", "-t", help="Title to display (if omitted will use file name).") + @click.option( + "--layout", + "-l", + default=[], + multiple=True, + show_default=True, + help="Layout name, eg, 'umap'." + ) + @click.option("--obs-names", default=None, metavar="", help="Name of annotation field to use for observations.") + @click.option("--var-names", default=None, metavar="", help="Name of annotation to use for variables.") + @click.option( + "--max-category-items", + default=1000, + metavar="", + show_default=True, + help="Categories with more distinct values than this will not be displayed.", + ) + @click.option( + "--diffexp-lfc-cutoff", + default=0.01, + show_default=True, + help="Relative expression cutoff used when selecting top N differentially expressed genes", + ) + @functools.wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + return wrapper + + +def parse_engine_args(layout, obs_names, var_names, max_category_items, diffexp_lfc_cutoff): + return { + "layout": layout, + "max_category_items": max_category_items, + "diffexp_lfc_cutoff": diffexp_lfc_cutoff, + "obs_names": obs_names, + "var_names": var_names, + } @click.command() @click.argument("data", metavar="", type=click.Path(exists=True, file_okay=True, dir_okay=False)) -@click.option( - "--layout", - "-l", - default=[], - multiple=True, - show_default=True, - help="Layout name, eg, 'umap'." -) -@click.option("--title", "-t", help="Title to display (if omitted will use file name).", metavar="") @click.option( "--verbose", "-v", @@ -49,22 +84,7 @@ BIG_FILE_SIZE_THRESHOLD = 100 * 2**20 # 100MB ) @click.option("--port", "-p", help="Port to run server on, if not specified cellxgene will find an available port.", metavar="", show_default=True) -@click.option("--obs-names", default=None, metavar="", help="Name of annotation field to use for observations.") -@click.option("--var-names", default=None, metavar="", help="Name of annotation to use for variables.") @click.option("--host", default="127.0.0.1", help="Host IP address") -@click.option( - "--max-category-items", - default=1000, - metavar="", - show_default=True, - help="Categories with more distinct values than this will not be displayed.", -) -@click.option( - "--diffexp-lfc-cutoff", - default=0.01, - show_default=True, - help="Relative expression cutoff used when selecting top N differentially expressed genes", -) @click.option( "--scripts", default=[], @@ -72,20 +92,21 @@ BIG_FILE_SIZE_THRESHOLD = 100 * 2**20 # 100MB help="Additional script files to include in html page", show_default=True, ) +@common_args def launch( data, - layout, - title, verbose, debug, - obs_names, - var_names, open_browser, port, host, + layout, + obs_names, + var_names, max_category_items, diffexp_lfc_cutoff, - scripts, + title, + scripts ): """Launch the cellxgene data viewer. This web app lets you explore single-cell expression data. @@ -98,6 +119,7 @@ def launch( > cellxgene launch --title """ + e_args = parse_engine_args(layout, obs_names, var_names, max_category_items, diffexp_lfc_cutoff) # Startup message click.echo("[cellxgene] Starting the CLI...") @@ -112,22 +134,22 @@ def launch( else: warnings.formatwarning = custom_format_warning - if scripts: - click.echo(r""" -/ / /\ \ \__ _ _ __ _ __ (_)_ __ __ _ -\ \/ \/ / _` | '__| '_ \| | '_ \ / _` | - \ /\ / (_| | | | | | | | | | | (_| | - \/ \/ \__,_|_| |_| |_|_|_| |_|\__, | - |___/ -The --scripts flag is intended for developers to include google analytics etc. You could be opening yourself to a -security risk by including the --scripts flag. Make sure you trust the scripts that you are including. - """) - scripts_pretty = ", ".join(scripts) - click.confirm(f"Are you sure you want to inject these scripts: {scripts_pretty}?", abort=True) - if not verbose: sys.tracebacklimit = 0 + if scripts: + click.echo(r""" + / / /\ \ \__ _ _ __ _ __ (_)_ __ __ _ + \ \/ \/ / _` | '__| '_ \| | '_ \ / _` | + \ /\ / (_| | | | | | | | | | | (_| | + \/ \/ \__,_|_| |_| |_|_|_| |_|\__, | + |___/ + The --scripts flag is intended for developers to include google analytics etc. You could be opening yourself to a + security risk by including the --scripts flag. Make sure you trust the scripts that you are including. + """) + scripts_pretty = ", ".join(scripts) + click.confirm(f"Are you sure you want to inject these scripts: {scripts_pretty}?", abort=True) + if not title: file_parts = splitext(basename(data)) title = file_parts[0] @@ -170,16 +192,8 @@ security risk by including the --scripts flag. Make sure you trust the scripts t mpl.use("TkAgg") from server.app.scanpy_engine.scanpy_engine import ScanpyEngine - args = { - "layout": layout, - "max_category_items": max_category_items, - "diffexp_lfc_cutoff": diffexp_lfc_cutoff, - "obs_names": obs_names, - "var_names": var_names, - } - try: - server.attach_data(ScanpyEngine(data, args), title=title) + server.attach_data(ScanpyEngine(data, e_args), title=title) except ScanpyFileError as e: raise click.ClickException(f"{e}") diff --git a/server/gui/cellxgene.qrc b/server/gui/cellxgene.qrc new file mode 100644 index 00000000..b7654cd7 --- /dev/null +++ b/server/gui/cellxgene.qrc @@ -0,0 +1,8 @@ + + + images/cellxgene_logo.png + images/properties_contract.svg + images/properties_expand.svg + images/properties_expand.svg + + diff --git a/server/gui/cellxgene_rc.py b/server/gui/cellxgene_rc.py new file mode 100644 index 00000000..07fda04b --- /dev/null +++ b/server/gui/cellxgene_rc.py @@ -0,0 +1,440 @@ +# -*- coding: utf-8 -*- + +# Resource object code +# +# Created: Wed Jun 19 15:02:04 2019 +# by: The Resource Compiler for PySide2 (Qt v5.12.3) +# +# WARNING! All changes made in this file will be lost! + +from PySide2 import QtCore + +qt_resource_data = b"\ +\x00\x00\x0d\xde\ +\x89\ +PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ +\x00\x01;\x00\x00\x00j\x08\x06\x00\x00\x00\xc3y\xf6!\ +\x00\x00\x00\x09pHYs\x00\x00\x0b\x13\x00\x00\x0b\x13\ +\x01\x00\x9a\x9c\x18\x00\x00\x00\x01sRGB\x00\xae\xce\ +\x1c\xe9\x00\x00\x00\x04gAMA\x00\x00\xb1\x8f\x0b\xfc\ +a\x05\x00\x00\x0dsIDATx\x01\xed\xdd\x7fn\ +\x1b\xc7\x15\x07\xf07C\xdbQ\x93\x00f\xfe\x88e7\ +\x05L\x9d J/PJ9@\xe4^@\x12\x90H\ +(\x0aT\xce\x09,\x9d\xc0\xf2\x1fE \xb9\x80\xd9\x0b\ +\xd4\xf2\x01,2'\xb0|\x023@\x0bG\xea\x1fQ\ +\xff\x09\x04\xd1;\xaf\xef-\xc9X\xa48\xb3?\xb8\xfc\ +e~?\x80b\x85\xbb\xab%g\xdf\xbe\x9d\x9d\x99\x1d\ +\x12\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\xc0\x9c24\x22\xa7\xab\x87L3l\xf1xk\ +de\x03\x00\xe3g\x09\x00`\x0e \xd9\x01\xc0\x5c@\ +\xb2\x03\x80\xb9\x80d\x07\x00s\x01\xc9\x0e\x00\xe6\xc2\x0d\ +\x02\x18\xe0m\xf5i\xd5Z\xae\xfb\x96KW{\xed\xee\ +\xf1\xd6f\xef6?T\xac-\xbd!\xbf\xa6\xf4r/\ +\x11\xc0\x04\xccD\xb2\xbb\xf3\xf2;\x1a\x85\xb3\xaf\x9f\x12\ +\x00\xcc\x07\xdc\xc6\x02\xc0\x5c@\xb2\x03\x80\xb9\x80d\x07\ +\x00s\x01\xc9\x0e\x00\xe6\x02\x92\x1d\x00\xcc\x05$;\x00\ +\x98\x0b31\xf4\x04CD\x00`X\xa8\xd9\x01\xc0\x5c\ +\xc0\x13\x14c\xf0\xa6\xfa\xac\xfcI\xa9\xb5\xee\x98\x96\xad\ +\xa1\x0a3W\x88\xcc\xb91t.\xbf\xcb\xbf\xa6\x11E\ +\xd1\x8b{\x8d\xbf4i\x08\xfa\xd4\x831\xbc&?\xf7\ +\xe5\xefW\x88\xb8,\x7f\xbb\xa9\xcb\x9c\xa3\xd7\xcc\xe6\xe8\ +^\xe3\xbb\x06\xc1o\xbaef-}\xa9\xc7E\xcbk\ +\xd019\xad\x1e\xac9k\xca\x83\xfeF\xc9\xb5N\xee\ +4\xfez\x92f\x7f\x1a\x0b\xb7JQ\xb5\xe4\x5c\xb5\xff\ +8\xe9~\x1d\xd1kr\xb6\x91\xf68\xbd]=\xdc\xf0\ +.tQ\xe3jL\xf5\xc6\x07-wc0b\xfeQ\ +\x02\xa46L\xfc%\xc5^dm\xe3\x8b\x97\xdf\x1e\xd1\ +\x04a\xf2N\x8f\x22&\xef\x8c\x1f\x9f2v\x97\x8cY\ +O\xb3\xbe\x04\x9e\x9e`\x9bY\x82NO\x9eO\xed\xbb\ +\x1d&~(\x05^N\xb1I\xd3\x92\xa9}~\xfc\xdd\ +^h\xa5\x0f\xfdq\xb1\xff|}\xb0v\x83\xe9q\xfb\ +\xc4\xf4\x93\xb2\xda\xd5\xb2\x92x~Eq\x82\xb8\xce\x11\ +\xef\xdd;\xde\xde\x0d\xfd\x9d<\xc7\x89$v\x16_~\ +\xfb\xcf\xd0J\xa1\xf3L\xf6\xf5\xfd\xdd\xe3\xed\xfd\xf6\xb1\ +t\xcf\x92?+\xef\x7f~\xbc\xfd=e\xf0\xdf\xd5\xa7\ +\x8f\x8a\x8e\xbdQ\xc1m\xec\x88\x9c}}\xb8S\xb2\xa5\ +Wi\x13\x9db\xa6\xaa&\x0b\x0d\xa04\xebkr\xf9\ +\xd8^\xbe\x92\x93m7e\xb0\xa9\x8a\xae/'\xc9\x1b\ +\xdd\x9e\xa6\x88&\x04\xca)\xcb\xb6g\xabO\x1f\xdf`\ +\xf3<\xe9\xe4WZVg\xab\x87\xcf\x0cS\xe2\xba>\ +\x9al>\xb1\xad7Y\x8f\x13\xb1\xab\xe9\xbe\xf3\x96\x8b\ +\xeeK\xe3\xb0}\xd12\x95\xa4\xf5\x1d\x99\x87\xb2~\x9d\ +R\xd0\xd8\xd1\x0b\xc0,\xc5\xdeDncC\xcf\xba\x0e\ +\xea\x8c\x98\xb5gcO\xf5j\xc7\xbcK9i@H\ +\xc2\xa3\xd0\x15\xf0\xac\xfa\xf7e\xb2\xa5z\x86@\xeb\xa7\ +\xb5\xb0\xba\x04\xdd\xca\xb0\xb7\xcfE\xd0\x93\x92\xb9\xb5{\ +V=\x5c\xb9\xd3\xd8JuK\xd8\xd5\xaeQ^\xd6%\ +14\xee\xf4\xd56\xfb\x9dvj\x22\x94\x81\x94\xf1F\ +\xde{\xa0\xd3\xea?\xd6\xc9\xbaZ\xde\xdb\x1c\xdd\xf7\xc7\ +\xb6\xb5,\x09oe\xa9\xb1y\x9ee[\xa9E\xads\ +\xc6$\xad\x17\xdc\x9fW\x0f\x1ej\x8d\xd0\xb7N\xbb\xbc\ +\xad&\xc5\x0a\xe53\x91\xd8C\xcd\xae`oW\x7f\xd8\ +\x90\x90\xd9\xa5!i\xc2\x93`\xa8\x0e\xdc\x87\x04\x1b\xdb\ +\x1b\xcf\x87Ht]\x1at\xcfi\xc24!\xc8I\xa6\ +'W\x99-\xd5%\xe1-\xa7\xdd\xf6\xfd\x89g*\x9a\ +\x18B\xb5\xe2\xa2\x8eM\xfa\xf7\xf6\xb4\xaa\x89\x8e\x86\xb7\ +\xfc\xa9\xbdLU\xdb\xefS\xa1\x1c\x0c\x19\x7f\x19^)\ +o\x1a\x8e\xc6\xde\xf10\xb5\xf9\xac\x90\xec\x0a\x14\x07\x02\ +\xd9\x92\xda\xfcm)\ +\xbf5\xff\x96\xe6\xcb\xfeW\xda\xb5\xba\x10>Z\xcc\x18\ +{rH\xca\xbf\xb3\x97\x1b\xf2\xab\xb7\x8d\xb0(3[\ +\xb3\xd3\xce\x85,?\xa3\xf6\xef\xb8\x16b*\xbe\xe5\xcc\ +\xee\xc5\xa0\xb1Xz\xa2k\x8d\xc2\xbf\x1d\xf7^a\x13\ +\xae\xac\xce\xb9\x81C\x07\xdaC#\xfc\xb5\x14\x09\xba?\ +\xd1\x04\xe9\xfbKS\xc3\xd3Z\xc0\xe0DG'\xbeD\ +\xf7\x8bl#\x89\xaeJ!lw\x07%\x11}\xcd\x19\ +J\xac\x09]\x95\x14\x0b\x9aX\x17=CU>\xe2\x9b\ +\xbb\x14 5\xae5J\xaf9 \xd1\xc5$N\x9e\x84\ +6\xd4\xf1\xa0\xfd\xafI[\xde7\xa1m\xc2\xb1\xe7\xad\ +\xb5\x1a\x09\xf2\xb1\xc4\x1enc\x0br\xc3r5\xb8\x82\ +1\x0d\xdf\xa2\x88\xf8\x85o\x99\x9c\xd8\xb7\xbb\xbf\xc7\xb7\ +\xb0\x01\x9aPCW}\xc7\xf4\xa3o\x99$\xbb*M\ +X(\xe1i\x8dF\x13\x9d\xf4L\x0eLt\xbf\xf2\xcd\ +\x15\xdf\xdf\xbd\xa0w\xe1\xf6?M>\x8do3%\xb4\ +\x90\xa4X`\xc3\xdeD\xf3Y\xdc\xe3\x1a\xbau\xbe^\ +\xe3\xca\xa9\x99ee\x8d=\xb9`xo7u\x8ch\ +8\xf6\xf8\xb5\x7f[[\xd4g\x0a\xc2mlA\xe4\x16\ +c\xd9\x04n\x93\xd8\xb9\x93\xc0\xc2\x9a\xd4\x1e\x1a\x83\x16\ +9\xb2\xbf\xd5\xfa\x8cu\xcb\xa1[Xc\xccIh\xfc\ +\x925\xe66\xf9Uh\x0a\xf8niu\xa8\xc2\xc7\xd4\ +\xd2\xb2\xe8M\x5c\x9dD\x17\x1a\x96\x91TnL\xce{\ +\x22\xe6!5\xa0`ME\xda\xa9\xce\xf3\x8e3\xb3L\ +\xa9{\xaa\x0be\xdfU\xa41\xc0\xbbX\x9f\x92\x18&\ +\xf6\xf4B\x96uhMVHv\x05)\x91\xb9\xcf\xc1\ +\xe5%\xef\x81\x94+b\x83\xd2`S\x09\x8f\xf72\x8f\ +$)\xe4\xeeq\x1cG\xc0\xa5\xe1Ix\x95k+\xa6\ +Ht\xed\xd5\xa8\x1c.6\x7f\xad;\x0fi\x96(\x87\ +\xda\x07\xe5v\xea\x19\xd9\x12\xe5\x11\xaa]\x8d\x96\xad\x84\ +\x96j;\xa31\xa5\x1d\xcai\x81.\xf4s\x8d4\xf6\ +p\x1b;&\x11EC\x1fH&\x1ei\xa0w\x02n\ +*xni\xdfK\x99\xe8:\xebV\xc2\x8bM\xa1'\ +\xd9\xe4\x12\xd2\xe8\x1863\xff\x99\x90\xec`j]\xb8\ +[\xfb\xbe\xce\x1by}s\x1aj\xa1\x83\xf1\x07\x97\xec\ +\xf8\x03\xf8LHv0\xb5>\xb5\x97\xcf|\xb5$\xe9\ +\xb4x\x9e\xb6\xdd\xcb\x14\x5cs\x83\xd9\x846\xbb\x82H\ +\x8f\xeaO6\xd0\xa0&mvC_\x19\x13OZ\xe6\ +\xa6\xac\xd3\xa4\x9c.haj\x92\x82>\x00\xef\x88B\ +\xc3,R?_)\xbd\x9f\xe7&\xd4\xb1Sp\xadE\ +n\xf9\x9al\x02\xb7\xce:\xf06g\xfb\x94td5\ +i\x12\x92\xf6;T\xec1\x8f#\xf6\x90\xec\x0a\x92\x94\ +\x88$\x11\xde'\x0a\x8f\x90O\xde\x89\x0eI\x08\xf5*\ +\x9a\x17\x8b\xf5\xad\x874\xe34\xd1\xc5\x0f\xdf_\xc5:\ +\xe5\x11\x9f\xf4\x0d\x84M\x99\xf0\x5c3\xd4\x93X\xe0p\ +\x8eX\xd2\x85O\x12\xd6\x93{\xc7\x83\xc7\xbfM\xafp\ +\x19\xb2t\xf2,&L\xc20i\xb8\x8d-\x08\x9b(\ +\x98\xc8\x22\xeb\xbe\xf2-{\xbbrP\xd3y\xc9\x06\xfd\ +\x5c\x9dr'8|\x85\xe2\x1e\xb1\xf5\xb4\x8f\xdeL\xea\ +\xf1\xb0$\xbeD\xe78ZY<\xde~@\xd7\x1f?\ +\xea&\xbc\x8a\xefoF\xae\x14.7\xa9A\x16Y\x1e\ +r\x1c\xc2\x8f}\xc9q\xa2Y\xe3\x06\x0f\x8d\xea\xcaR\ +\x86\x93\x8a=$\xbb\x82,\xb8\x85\xa3\xd0rc\xfcc\ +\xaf\xacI\xf7\xf4\x82\x0eQ\x09=m!\xcaif\xc7\ +h\xcf\x83\xd7\xaa\xa7\x9d7o\x5cB\x89\xae[s\x8b\ +\x9f\x92\xc8\x98\xf0\xfe\xd0\xd8:I*\xb7\xd0\x03\xe9Y\ +{\x22?r\xb7j\xa1\xe5\xfa\xd8W\x9a\xb2o\xcf\x19\ +w\xf0f\x8c\x13\x00x\xb5\xcb?\xf8\x9cp\xf9\x13\xdb\ +zL\x09\xba\x03\xc3'\x11{Hv\x05\xd1\x91\xefr\ +B5|\xcb}\x01~\x1a\xbff*\xbe\xed\xe2)\xb3\ +\xaf\xfe\x7f`\xf4\xbd\x8a'`\x94\xa41\xe8\xc4\xd7@\ +\xd3\x07\xd4;s\x91-\xc7\x13)\xea|kS M\ +\xa2\xeb\xca\x93\xf0\xa4\xfd/i2\x84G:\x8f[\xff\ +\xab:\x83\x8a\xcdX\x13K\x8a\x85\xf6\xfb\x89\xe7,|\ +4\xa8\x96\xa3\xaf\xe9\xb2x\xf2W2\xf14\x5c\xd30\ +\xd1jR\x19\xea\xf1\x93\xe4\xfc<\x14{:\xd9,M\ +(\xf6f\xb6\xcdnT\x13z\x0e#\xe2h\xcf\x9aR\ +\xd5\xb7\xbc3K\xeb\x86\x84\xc5\x89\x8e\xed\x92+\xcdZ\ +b\x97\xbe\xeb\x9d\x0fm\xc1\xdd\xdao\x99\xd6Nh,\ +\x97\x06\x9d\x9c \x1br\x0b\xdcp\xdcnX\xb6:\xd6\ +\xcc\xb4\x96\xb9\xbf\xa3\xc4\xba\xfd\xb3\xea\xe1\xeb\xac\x13f\ +\x16)K\xa2\xeb\xd2\x84w\xbar\xd0\xff\xac\xb0\xb7\x0d\ +\x8f9:J\x1a\xf4*\xed\xae\x8f\xe5\xf8\xecpg\xe2\ +\x05\xe9\xb8\xa8\xea@\xee\xd0`q\x9f\xa4XP\x1a\x0f\ +R\x1b\xd2\xd9\x81O\xba\xc7\xc9\xc4O\x88\xb4$\x19\xf4\ +(O\xc3D\xab\x1a{\x97\xe6r=<)\x82Y\x93\ +\xf7\xba\x962\xf6\x1e\x8f3\xf6P\xb3+P\xe76\xb3\ +\x91\xb0ZE\x03B\x82z#i\xf2M\x9d\x9a\xa7?\ +\xb8\xb5\xd6\xc0\xcc\xa9\x1a\x82\xb56\xa9\xfb\x89\xf7e\xa8\ +\xea\xd9_9\xcb0\x8e\xa2\xc5\xfb\xe5\xbe^\xd7\x84D\ +\xd7\xe5\xab\xe1\x19\xba\xfe\xb0|\xcac\xd3\xd9\xbe]f\ +\xc1\x87\xf9\x13\xe8\xfe\xa4\xfc\x9f$\xad\xa7\xc7\xe4\xeaq\ +\x22\xf2>\x0e6\xf1\x89V5\xf6\x1c\xbb\x22c\xef3\ +\x89\xbd\x7f\x8d+\xf6\x90\xec\x0a&W\xf4\xcd\xbcs\xa0\ +\xf5\x90\x13\x9e]4\xf0\x09\x82\xc5\xc6\xf6Q\xf0\xe9\x82\ +\xec\xfb\x9a\x988\xa1qk\xe5\xca{I\x95\xe8\xba4\ +\xe1\xf5&1\xde\xbb\xdb\x18<\xa5\xb8\x1e\x9b\x84\xb6\xbb\ +B\xdd\xd5\x9e\xf1\x14s\xc7\xa5\xc6T\x9et\xc7\x92&\ +\xf1Y\x8d=$\xbb\x82\xc5S\x02\xb1[\x19*\xe1\xc9\ +\x09o\xb8\xf5 t\xc2'>N\x95a_Y\x92\xcb\ +(\xc4\x13x\xb6'\xaf\xcc\xf5^n\xf2\xcd\x07\xed\x89\ +/yo1\xf0-_\xdd\xc4:\xce\x84\xa7\xc98M\ +\x0d/\x91|>-\x9biyv\xb9\xa0\xd8{#q\ +\xfe\xe7q\xc5\x1e\x92\xdd\x08t\x13^\xca\xdb\xa6\x1e\xf1\ +\xec\xb5\x12\xd4i\xbe\x874\x0e:\x9d\x116gb\xd5\ +\x93\xf0W\xbe\xf9\xd54|\xe1\x8e\xbc\x87\xdab}k\ +)\xcf{\xd1\xdb+\xd9\xf6\xab\xc5\x84\xaf3Tq\xb9\ +jM2m\x99i\x0d{\xc8IM\xb5\x86\xd7N\xe6\ +\xd9\x8fSw\xf6d\xfd|\xd3p\x9c\xba:\xb1\xb7\x94\ +;\xf6\xa4L%\xf6\xfe\x98\xf6\xfbv\x8b0\x91\x0e\x8a\ +\xac\x93i\x8ec\xf2\xcd\xa2u\x02sE\xda#6J\ +&\x9e%\xb7\x1aZ_\x93\x5cd\xf9\xc9\x17\xc7\xdbG\ +\x94Ag\xc6\x94\xa5\xb4\xfb\xd1\x93\xe7\x9d\xf4\xaa\x19\x8e\ +\xf6\xa7\xe9\xe4\x19'=\xc1\xb4\xb1_\xae\xf5\xbbV'\ +-\x1d\xd0\xe0\xae\xe5\xa4=\xdf\x17|k\x7f\xc1\x5c>\ +4y\xbf^\xacC\x93\xb9\xfcSK{\x9ct\x98\x87\ +\xf6~\xea\xfe\x97\xea\xd3\xf9\x0cp'~\xf2\xc4\xdeQ\ +\xea\x99~\x0a4\xdc\x11\x0c\xc0\x97d\xf7\xd2\xb6\x96\x05\ +\xbaX\x96\xbat\xe5\xfdt9\xaei\x9d=\xff\x95n\ +5\x8a\xbc=\x89\xc7e\xc9~$\x00oG\xcc\x9f\x95\ +\x8c\xf9E\xda\xab\xfe\xa7\x03C\xe75\xc1\x85t\xcbK\ +\x8f\x8b\x96\x15G\xfc\xd3\xd5c\x22\xb1\xac\x1d\x03\xdeG\ +\xd7$)mf}\x22\x22\x14\x0f\x8e\xdc\xc9\xac\x1e'\ +-KK\xb6lJ\xe6\xfe\xb4\xc5\x1e\x92\x9dG\xd1\xc9\ +\x0e\xa6\x8b~\xabY\xda[(\x1d\xd8\x1b\xea\x99\xd5\xa6\ +\x84I\xd4T \x1b\xb4\xd9\xc1\xdc\xd1D\xc7\xf6\xe6\xab\ +4\xa3\xf8\x93\x06}\x8bs$\xba\xd9\x80\x89\x00`\xae\ +\xb4\xbf`\xdc\xc6\xe3\xd5tP\xef\xd9\xea\xe1Z\xcb\xd8\ +\xbd\xcb\xa8\xd4\xd3\x94\xa0\xdf\xb9\xd0\xfe&7\x0e\x7fm\ +%Q\xa66V\x98\x1c$;\x98+\xfd_\xf2,\xc9\ +j\xf9\x06\xbb\xe77\xac\xd3\xdb\xd5\xa6\xbe&\x9d\x11\xe5\ +\xf6\x93-\xc9-\x19\xbe\xb1\x900}\x90\xec`nt\ +\xbe\xb8:PS3\x15\xfdo\xfa\xc6f\xdeC\x87\xcf\ +\xec\x18Y\xb2C\x03?L\x1bv\xb6\x5c\x5c+ux\ +\x003L\x1ftP\xc0\xdc\xf8}c{\x7f\xd8\x91\xff\ +:VLnq\xbfG\xa2\x9b=\xa8}\xc1\xdc\x89\x1f\ +<\x97\xb6;kL\xea)\x860\x18{\xf6!\xd9\xc1\ +\xdc\xea\xcc\xb6Q\xb5\xd6~\xa3S9\xc9\xc9Py?\ +u\x167\xe5\xb5\xf3\x88\xe8Gc\xa2\x93\x0b\xb7p4\ +\xbd\xdff\x06\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x01\xff\x07\xb4<2M\x93'\x06\ +\xcb\x00\x00\x00\x00IEND\xaeB`\x82\ +\x00\x00\x03X\ +<\ +?xml version=\x221.\ +0\x22 encoding=\x22utf\ +-8\x22?>\x0a\x0a\x0a\x0a path{fill\ +:rgb(150, 146, 1\ +44)}\x0a polygon\ +{fill:rgb(150, 1\ +46, 144)}\x0a ci\ +rcle{fill:rgb(15\ +0, 146, 144)}\x0a \ + rect{fill:rgb(\ +150, 146, 144)}\x0a\ +\x0a\x0a\ +\x0a\ +\x00\x00\x03Y\ +<\ +?xml version=\x221.\ +0\x22 encoding=\x22utf\ +-8\x22?>\x0a\x0a\x0a\x0a path{fil\ +l:rgb(150, 146, \ +144)}\x0a polygo\ +n{fill:rgb(150, \ +146, 144)}\x0a c\ +ircle{fill:rgb(1\ +50, 146, 144)}\x0a \ + rect{fill:rgb\ +(150, 146, 144)}\ +\x0a\x0a\ +\x0a\x0a\ +\x00\x00\x03X\ +<\ +?xml version=\x221.\ +0\x22 encoding=\x22utf\ +-8\x22?>\x0a\x0a\x0a\x0a path{fill\ +:rgb(150, 146, 1\ +44)}\x0a polygon\ +{fill:rgb(150, 1\ +46, 144)}\x0a ci\ +rcle{fill:rgb(15\ +0, 146, 144)}\x0a \ + rect{fill:rgb(\ +150, 146, 144)}\x0a\ +\x0a\x0a\ +\x0a\ +" + +qt_resource_name = b"\ +\x00\x08\ +\x05\xe2Y'\ +\x00l\ +\x00o\x00g\x00o\x00.\x00p\x00n\x00g\ +\x00\x08\ +\x0aaZ\xa7\ +\x00i\ +\x00c\x00o\x00n\x00.\x00p\x00n\x00g\ +\x00\x0d\ +\x0dq\x0b\x87\ +\x00c\ +\x00o\x00l\x00l\x00a\x00p\x00s\x00e\x00d\x00.\x00s\x00v\x00g\ +\x00\x0c\ +\x07)\x8aG\ +\x00e\ +\x00x\x00p\x00a\x00n\x00d\x00e\x00d\x00.\x00s\x00v\x00g\ +" + +qt_resource_struct = b"\ +\x00\x00\x00\x00\x00\x02\x00\x00\x00\x04\x00\x00\x00\x01\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\ +\x00\x00\x00L\x00\x00\x00\x00\x00\x01\x00\x00\x14\x9b\ +\x00\x00\x00\x16\x00\x00\x00\x00\x00\x01\x00\x00\x0d\xe2\ +\x00\x00\x00,\x00\x00\x00\x00\x00\x01\x00\x00\x11>\ +" + +def qInitResources(): + QtCore.qRegisterResourceData(0x01, qt_resource_struct, qt_resource_name, qt_resource_data) + +def qCleanupResources(): + QtCore.qUnregisterResourceData(0x01, qt_resource_struct, qt_resource_name, qt_resource_data) + +qInitResources() diff --git a/server/gui/images/cellxgene_logo.png b/server/gui/images/cellxgene_logo.png new file mode 100644 index 00000000..6dc643da Binary files /dev/null and b/server/gui/images/cellxgene_logo.png differ diff --git a/server/gui/images/favicon.png b/server/gui/images/favicon.png new file mode 100644 index 00000000..58f43344 Binary files /dev/null and b/server/gui/images/favicon.png differ diff --git a/server/gui/images/properties_contract.svg b/server/gui/images/properties_contract.svg new file mode 100644 index 00000000..abaaae9c --- /dev/null +++ b/server/gui/images/properties_contract.svg @@ -0,0 +1,14 @@ + + + + + + diff --git a/server/gui/images/properties_expand.svg b/server/gui/images/properties_expand.svg new file mode 100644 index 00000000..966b259e --- /dev/null +++ b/server/gui/images/properties_expand.svg @@ -0,0 +1,14 @@ + + + + + + diff --git a/server/gui/main.py b/server/gui/main.py index e1258d70..c827c082 100644 --- a/server/gui/main.py +++ b/server/gui/main.py @@ -8,13 +8,17 @@ 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.options_parser import parse_opt_string +from server.cli.launch import parse_engine_args from server.gui.workers import Worker, SiteReadyWorker -from server.gui.utils import WINDOWS, LINUX, MAC, FileLoadSignals, Emitter, WorkerSignals -from server.utils.constants import MODES +from server.gui.utils import WINDOWS, LINUX, MAC, FileLoadSignals, Emitter, WorkerSignals, FileChanged +from server.utils.errors import OptionsError from server.utils.utils import find_available_port if WINDOWS or LINUX: @@ -24,17 +28,19 @@ if WINDOWS or LINUX: # Configuration # TODO remember this or calculate it? -WIDTH = 1024 -HEIGHT = 768 +WIDTH = 1300 +HEIGHT = 800 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 @@ -49,6 +55,7 @@ class MainWindow(QMainWindow): def showBrowser(self): self.stacked_layout.setCurrentIndex(BROWSER_INDEX) + def restartOnError(self): self.window().shutdownServer() # close emitter on error/finished @@ -120,8 +127,12 @@ class MainWindow(QMainWindow): 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: @@ -147,57 +158,105 @@ class LoadWidget(QFrame): logo_layout = QHBoxLayout() logo_layout.setContentsMargins(0, 0, 0, 20) - load_layout = QGridLayout() - load_layout.setContentsMargins(0, 0, 0, 0) - load_layout.setSpacing(0) + file_layout = QVBoxLayout() + message_layout = QHBoxLayout() message_layout.setContentsMargins(0, 0, 0, 0) self.serverError = False - self.title = "" - self.label = QLabel("cellxgene") + 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 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) + self.file_area = FileArea() + self.file_name.signals.changed.connect(self.updatePath) + + self.launch_widget = QPushButton("Launch cellxgene") + self.launch_widget.setEnabled(False) + self.launch_widget.clicked.connect(self.onLoad) + + self.progress = QProgressBar() + self.progress.setTextVisible(False) + + self.advanced_options = CLIOptionsArea(self) + + file_layout.addWidget(self.file_area) + file_layout.addWidget(self.advanced_options) + 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(self.MAX_CONTENT_WIDTH) - message_layout.addWidget(self.error_label, alignment=Qt.AlignTop) + message_layout.addWidget(self.error_label) + + # Options Form # Layout - for l in [logo_layout, load_layout, message_layout ]: + for l in [logo_layout, file_layout, message_layout]: load_ui_layout.addLayout(l) - load_ui_layout.setStretch(2, 10) + #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 updateEmbedding(self, idx): - self.embedding_selection = [MODES[idx]] + 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) + self.advanced_options.reset() + + def updateProgress(self): + curr_val = self.progress.value() + next_val = (curr_val + 1) % 100 + self.progress.setValue(next_val) + + def resetProgress(self): + print("here i am") + self.progress.setValue(0) + self.loading_layout.setCurrentIndex(0) + self.timer.stop() def createScanpyEngine(self, file_name): + try: + self.advanced_options.parse() + except OptionsError as e: + self.signals.error.emit(f"Options Error: {e}") + return + title = self.advanced_options.title + if not title: + title = splitext(basename(file_name))[0] self.window().setupServer() - worker = Worker(self.window().parent_conn, self.window().child_conn, file_name, self.title, host="127.0.0.1", port=GUI_PORT, - layout=self.embedding_selection) + 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.advanced_options.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) @@ -208,15 +267,14 @@ class LoadWidget(QFrame): self.window().child_conn.close() 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] - if file_name: - self.signals.selectedFile.emit(file_name) + 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) @@ -228,6 +286,7 @@ class LoadWidget(QFrame): def onServerReady(self): if not self.serverError: + self.resetProgress() self.window().cef_widget.browser.Navigate(self.window().url) self.window().showBrowser() @@ -236,13 +295,105 @@ class LoadWidget(QFrame): if server_error: self.serverError = True # Report error and switch to load screen - self.window().shutdownServer() + self.window().shutdownServer() + self.resetProgress() self.window().stacked_layout.setCurrentIndex(LOAD_INDEX) self.error_label.setText(f"Error: {err}") self.error_label.resize(self.MAX_CONTENT_WIDTH, self.error_label.height()) + self.window().repaint() onServerError = partialmethod(onError, server_error=True) +class CLIOptionsArea(QFrame): + def __init__(self, parent): + super(CLIOptionsArea, self).__init__(parent) + self.engine_options = {} + self.setFixedWidth(500) + self.title = None + self.form_layout = QFormLayout() + self.form_layout.setContentsMargins(0,0,0,0) + self.cli_label = QLabel("CLI Options:") + self.cli_label.setToolTip("Additional options can be passed as cli parameters. See cellxgene --help for more info") + self.cli_widget = QLineEdit() + self.form_layout.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow) + self.form_layout.addRow(self.cli_label, self.cli_widget) + self.setLayout(self.form_layout) + + def reset(self): + self.cli_widget.setText("") + self.engine_options = {} + self.title = None + + def parse(self): + opts = parse_opt_string(self.cli_widget.text()) + self.title = opts.pop("title", None) + self.engine_options = parse_engine_args(**opts) + + +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): + super(FileArea, self).__init__() + self.setFrameShape(QFrame.Box) + self.setMinimumHeight(100) + self.setFixedWidth(500) + self.setAcceptDrops(True) + self.instructions = QLabel(self) + self.instructions.setText("Drag & Drop a h5ad file to load or open") + self.instructions.setGeometry(10, 10, 500, self.instructions.height()) + self.loadButton = QPushButton("Open...", parent=self) + x_pos = (500 - 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, 500, 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) + + 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) + else: + e.ignore() + def main(): # This generates an error.log file on error @@ -256,6 +407,9 @@ def main(): 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_() diff --git a/server/gui/options_parser.py b/server/gui/options_parser.py new file mode 100644 index 00000000..ba0e64a1 --- /dev/null +++ b/server/gui/options_parser.py @@ -0,0 +1,27 @@ +import shlex + +import click + +from server.cli.launch import common_args +from server.utils.errors import OptionsError + + +@click.command() +@common_args +def cli(**kwargs): + pass + + +def parse_opt_string(opts): + context = click.Context(cli) + parser = click.OptionParser(context) + for command in context.command.params: + command.add_to_parser(parser, context) + + try: + opts, args, param_order = parser.parse_args(shlex.split(opts)) + for param in cli.params: + value, args = param.handle_parse_result(context, opts, args) + except click.ClickException as ce: + raise OptionsError(ce.message) from ce + return dict(context.params) diff --git a/server/gui/utils.py b/server/gui/utils.py index 3a76d572..b2149461 100644 --- a/server/gui/utils.py +++ b/server/gui/utils.py @@ -41,6 +41,11 @@ class SiteReadySignals(QObject): class FileLoadSignals(QObject): selectedFile = Signal(str) + error = Signal(str) + + +class FileChanged(QObject): + changed = Signal(bool) class Emitter: diff --git a/server/gui/workers.py b/server/gui/workers.py index 157b72c0..62320ab8 100644 --- a/server/gui/workers.py +++ b/server/gui/workers.py @@ -21,13 +21,13 @@ class EmittingProcess(Process): class Worker(EmittingProcess): - def __init__(self, parent_conn, child_conn, data_file, title, host, port, layout=["umap"], *args, **kwargs): + def __init__(self, parent_conn, child_conn, data_file, host, port, title, engine_options, *args, **kwargs): super(Worker, self).__init__(parent_conn, child_conn) self.data_file = data_file - self.layout = layout - self.title = title self.host = host self.port = port + self.title = title + self.engine_options = engine_options def run(self): super(Worker, self).run() @@ -47,12 +47,12 @@ class Worker(EmittingProcess): # load data try: args = { - "layout": self.layout, "max_category_items": 100, "diffexp_lfc_cutoff": 0.01, "obs_names": None, "var_names": None, } + args.update(self.engine_options) data = ScanpyEngine(self.data_file, args) server.attach_data(data, self.title) self.emit("ready") diff --git a/server/test/test_gui_utils.py b/server/test/test_gui_utils.py new file mode 100644 index 00000000..ca313c52 --- /dev/null +++ b/server/test/test_gui_utils.py @@ -0,0 +1,32 @@ +import unittest + +from server.gui.options_parser import parse_opt_string +from server.utils.errors import OptionsError + +default_opts = {'title': None, 'layout': (), 'obs_names': None, 'var_names': None, 'max_category_items': 1000, + 'diffexp_lfc_cutoff': 0.01} + + +class UtilsTest(unittest.TestCase): + + def test_empty_opts(self): + result = parse_opt_string("") + self.assertEqual(result, default_opts) + + def test_one_opts(self): + result = parse_opt_string("--title abcde") + expected_opts = dict(default_opts) + expected_opts["title"] = "abcde" + self.assertEqual(result, expected_opts) + + def test_malformed_opts(self): + with self.assertRaises(OptionsError): + parse_opt_string("--ewwwww lkafsjkl") + + def test_complex_opts(self): + result = parse_opt_string( + "--title abcde --obs-names zzzzz --var-names 'xxx yyy' --max-category-items 234907 " + "--diffexp-lfc-cutoff 0.00003847382") + expected_opts = {'title': 'abcde', 'layout': (), 'obs_names': 'zzzzz', 'var_names': 'xxx yyy', + 'max_category_items': 234907, 'diffexp_lfc_cutoff': 3.847382e-05} + self.assertEqual(result, expected_opts) diff --git a/server/utils/errors.py b/server/utils/errors.py new file mode 100644 index 00000000..adf78093 --- /dev/null +++ b/server/utils/errors.py @@ -0,0 +1,7 @@ +class OptionsError(Exception): + """ + Raised when user specified cli options specified fail parsing + """ + + def __init__(self, message): + self.message = message diff --git a/setup.cfg b/setup.cfg index 9b380f1d..75c9a1bf 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,4 +1,6 @@ [flake8] max-line-length = 120 ignore = E203, W503 -exclude = server/app/util/fbs/NetEncoding/ +exclude = + server/app/util/fbs/NetEncoding/, + server/gui/cellxgene_rc.py