mirror of
https://github.com/chanzuckerberg/cellxgene.git
synced 2026-10-04 01:08:13 +08:00
CLI refactor (#396)
* refactor cli to improve ux and enable easy incorporation of prepare as a subcommand * switches to use click, which removes some boilerplate and gets us some improved ux for free * changes the entry point for the cli * changes the name of the browser option to --open and makes the default false
This commit is contained in:
@@ -14,6 +14,7 @@ install:
|
|||||||
script:
|
script:
|
||||||
- set -eo pipefail
|
- set -eo pipefail
|
||||||
- flake8 server/app/
|
- flake8 server/app/
|
||||||
|
- flake8 server/cli/
|
||||||
- npm run --prefix client/ build
|
- npm run --prefix client/ build
|
||||||
- npm run --prefix client/ test
|
- npm run --prefix client/ test
|
||||||
- pytest -s server/test
|
- pytest -s server/test
|
||||||
|
|||||||
+2
-2
@@ -8,5 +8,5 @@ if __package__ is None:
|
|||||||
__package__ = PKG_PATH.name
|
__package__ = PKG_PATH.name
|
||||||
|
|
||||||
# Main thing
|
# Main thing
|
||||||
from .app.app import main
|
from .cli.cli import cli
|
||||||
main()
|
cli()
|
||||||
|
|||||||
+1
-124
@@ -1,8 +1,4 @@
|
|||||||
import argparse
|
|
||||||
import logging
|
|
||||||
import os
|
import os
|
||||||
import sys
|
|
||||||
import webbrowser
|
|
||||||
|
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
from flask_caching import Cache
|
from flask_caching import Cache
|
||||||
@@ -11,7 +7,7 @@ from flask_cors import CORS
|
|||||||
from flask_restful_swagger_2 import get_swagger_blueprint
|
from flask_restful_swagger_2 import get_swagger_blueprint
|
||||||
|
|
||||||
from .rest_api.rest import get_api_resources
|
from .rest_api.rest import get_api_resources
|
||||||
from .util.utils import Float32JSONEncoder, whole_number
|
from .util.utils import Float32JSONEncoder
|
||||||
from .web import webapp
|
from .web import webapp
|
||||||
|
|
||||||
REACTIVE_LIMIT = 1_000_000
|
REACTIVE_LIMIT = 1_000_000
|
||||||
@@ -32,7 +28,6 @@ app.config.update(
|
|||||||
# Application Data
|
# Application Data
|
||||||
data = None
|
data = None
|
||||||
|
|
||||||
|
|
||||||
# A list of swagger document objects
|
# A list of swagger document objects
|
||||||
docs = []
|
docs = []
|
||||||
resources = get_api_resources()
|
resources = get_api_resources()
|
||||||
@@ -45,121 +40,3 @@ app.register_blueprint(
|
|||||||
description="An API connecting ExpressionMatrix2 clustering algorithm to cellxgene"))
|
description="An API connecting ExpressionMatrix2 clustering algorithm to cellxgene"))
|
||||||
|
|
||||||
app.add_url_rule("/", endpoint="index")
|
app.add_url_rule("/", endpoint="index")
|
||||||
|
|
||||||
|
|
||||||
def create_cli():
|
|
||||||
parser = argparse.ArgumentParser(formatter_class=argparse.RawTextHelpFormatter)
|
|
||||||
parser.description = """
|
|
||||||
synopsis:
|
|
||||||
cellxgene <command> <data> [options]
|
|
||||||
|
|
||||||
description:
|
|
||||||
|
|
||||||
cellxgene is a local web application for exploring single cell expression.
|
|
||||||
"""
|
|
||||||
parser.add_argument("-V", "--version", help="show version and exit")
|
|
||||||
subparsers = parser.add_subparsers(dest="command")
|
|
||||||
subparsers.required = True
|
|
||||||
launch_group = subparsers.add_parser("launch", help="launch web application",
|
|
||||||
formatter_class=argparse.RawTextHelpFormatter)
|
|
||||||
launch_group.description = """
|
|
||||||
cellxgene launches a local web application for exploring single cell expression data.
|
|
||||||
|
|
||||||
Data must be in a format that cellxgene expects [[ how to format ]]
|
|
||||||
|
|
||||||
examples:
|
|
||||||
To run with the example dataset:
|
|
||||||
|
|
||||||
cellxgene example_dataset/pbmc3k.h5ad --title PBMC3K
|
|
||||||
|
|
||||||
To run with your own data with tsne layout:
|
|
||||||
|
|
||||||
cellxgene <your data file> --title <your title> -l tsne
|
|
||||||
|
|
||||||
To indicate that the human-readable variable annotation is named 'gene_names', and the human-readable observation
|
|
||||||
is 'cell_names':
|
|
||||||
|
|
||||||
cellxgene mydata.h5ad -var-name gene_names -obs-name cell_names
|
|
||||||
"""
|
|
||||||
launch_group.epilog = """
|
|
||||||
annotation names:
|
|
||||||
The data viewer requires a unique, human readable name for each observation and variable. These are used for
|
|
||||||
various application features, such as the ability to view expression by gene. When launching cellxgene, appropriate
|
|
||||||
observation and variable annotations must be identified.
|
|
||||||
|
|
||||||
If --obs-name or --var-name parameters are specified, values in the named annotations will be used. If not
|
|
||||||
specified, the observation and variable index values will name each respectively. An error will generated if the
|
|
||||||
values for each are not unique.
|
|
||||||
"""
|
|
||||||
launch_group.add_argument("data", metavar="data", help="file containing the data to display")
|
|
||||||
launch_group.add_argument("--title", "-t", help="title to display -- if this is omitted the title will be the name "
|
|
||||||
"of the data file.")
|
|
||||||
launch_group.add_argument(
|
|
||||||
"--listen-all",
|
|
||||||
help="bind to all interfaces (this makes the server accessible beyond this computer)",
|
|
||||||
action="store_true")
|
|
||||||
launch_group.add_argument("--port", help="port to run server on", type=int, default=5005)
|
|
||||||
launch_group.add_argument("-v", "--verbose", action="store_true",
|
|
||||||
help="more verbose output, including outputting warnings and every REST request")
|
|
||||||
launch_group.add_argument("--debug", action="store_true", help=argparse.SUPPRESS)
|
|
||||||
launch_group.add_argument("--no-open", help="do not launch the webbrowser", action="store_false",
|
|
||||||
dest="open_browser")
|
|
||||||
launch_group.add_argument("--obs-names", help="Annotation name to use as unique, human-readable observation name")
|
|
||||||
launch_group.add_argument("--var-names", help="Annotation name to use as unique, human-readable variable name")
|
|
||||||
launch_group.add_argument(
|
|
||||||
"--max-category-items",
|
|
||||||
type=whole_number,
|
|
||||||
help="Limit for the cardinality of a categorical annotation, beyond which the"
|
|
||||||
" annotation will not be available for user selection in the front-end",
|
|
||||||
default=100)
|
|
||||||
# TODO scanpy specific; rethink when we add another engine
|
|
||||||
computation_group = launch_group.add_argument_group('computational arguments')
|
|
||||||
# TODO these choices should be generated from the actual available methods see GH issue #94
|
|
||||||
computation_group.add_argument("-l", "--layout", choices=["umap", "tsne"], default="umap",
|
|
||||||
help="Algorithm to use for graph layout")
|
|
||||||
computation_group.add_argument("-d", "--diffexp", choices=["ttest"], default="ttest",
|
|
||||||
help="Algorithm to used to calculate differential expression")
|
|
||||||
return parser
|
|
||||||
|
|
||||||
|
|
||||||
def run_scanpy(args):
|
|
||||||
title = args.title
|
|
||||||
if not title:
|
|
||||||
file_parts = os.path.splitext(os.path.basename(args.data))
|
|
||||||
title = file_parts[0]
|
|
||||||
if args.listen_all:
|
|
||||||
host = "0.0.0.0"
|
|
||||||
else:
|
|
||||||
host = "127.0.0.1"
|
|
||||||
cellxgene_url = f"http://{host}:{args.port}"
|
|
||||||
api_base = f"{cellxgene_url}/api/"
|
|
||||||
app.config.update(
|
|
||||||
DATASET_TITLE=title,
|
|
||||||
CXG_API_BASE=api_base
|
|
||||||
)
|
|
||||||
|
|
||||||
if not args.verbose:
|
|
||||||
log = logging.getLogger('werkzeug')
|
|
||||||
log.setLevel(logging.ERROR)
|
|
||||||
from .scanpy_engine.scanpy_engine import ScanpyEngine
|
|
||||||
print(f"Loading data from {args.data} (this may take a while)")
|
|
||||||
app.data = ScanpyEngine(args.data, args)
|
|
||||||
print(f"Launching cellxgene")
|
|
||||||
if args.open_browser:
|
|
||||||
webbrowser.open(cellxgene_url)
|
|
||||||
print(f"Please go to {cellxgene_url}")
|
|
||||||
app.run(host=host, debug=args.debug, port=args.port)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = create_cli()
|
|
||||||
args = parser.parse_args()
|
|
||||||
# Debug sets up developer mode
|
|
||||||
if args.debug:
|
|
||||||
args.verbose = True
|
|
||||||
args.open_browser = False
|
|
||||||
if not args.verbose:
|
|
||||||
sys.tracebacklimit = 0
|
|
||||||
# TODO pick engine based on input file
|
|
||||||
print("cellxgene starting...\n")
|
|
||||||
run_scanpy(args)
|
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ class CXGDriver(metaclass=ABCMeta):
|
|||||||
|
|
||||||
def __init__(self, data, args):
|
def __init__(self, data, args):
|
||||||
self.data = self._load_data(data)
|
self.data = self._load_data(data)
|
||||||
self.layout_method = args.layout
|
self.layout_method = args['layout']
|
||||||
self.diffexp_method = args.diffexp
|
self.diffexp_method = args['diffexp']
|
||||||
self.max_category_items = args.max_category_items
|
self.max_category_items = args['max_category_items']
|
||||||
self.cluster = None
|
self.cluster = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ class ScanpyEngine(CXGDriver):
|
|||||||
|
|
||||||
def __init__(self, data, args):
|
def __init__(self, data, args):
|
||||||
super().__init__(data, args)
|
super().__init__(data, args)
|
||||||
self._alias_annotation_names(Axis.OBS, args.obs_names)
|
self._alias_annotation_names(Axis.OBS, args['obs_names'])
|
||||||
self._alias_annotation_names(Axis.VAR, args.var_names)
|
self._alias_annotation_names(Axis.VAR, args['var_names'])
|
||||||
self._validate_data_types()
|
self._validate_data_types()
|
||||||
self.cell_count = self.data.shape[0]
|
self.cell_count = self.data.shape[0]
|
||||||
self.gene_count = self.data.shape[1]
|
self.gene_count = self.data.shape[1]
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import click
|
||||||
|
|
||||||
|
from .launch import launch
|
||||||
|
|
||||||
|
|
||||||
|
@click.group(name='cellxgene', context_settings=dict(max_content_width=85))
|
||||||
|
@click.version_option(version='0.0.1', prog_name='cellxgene', message='[%(prog)s] Version %(version)s')
|
||||||
|
def cli():
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
cli.add_command(launch)
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import sys
|
||||||
|
import click
|
||||||
|
import logging
|
||||||
|
import webbrowser
|
||||||
|
|
||||||
|
from os.path import splitext, basename
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
@click.argument('data', metavar='<data file>', type=click.Path(exists=True, file_okay=True, dir_okay=False))
|
||||||
|
@click.option('--layout', '-l', type=click.Choice(['umap', 'tsne']), default='umap', show_default=True,
|
||||||
|
help='Method for layout.')
|
||||||
|
@click.option('--diffexp', '-d', type=click.Choice(['ttest']), default='ttest', show_default=True,
|
||||||
|
help='Method for differential expression.')
|
||||||
|
@click.option('--title', '-t', help='Title to display (if omitted will use file name).', metavar='')
|
||||||
|
@click.option('--verbose', '-v', is_flag=True, default=False, show_default=True,
|
||||||
|
help='Provide verbose output, including warnings and all server requests.')
|
||||||
|
@click.option('--debug', '-d', is_flag=True, default=False, show_default=True,
|
||||||
|
help='Run in debug mode.')
|
||||||
|
@click.option('--open', '-o', 'open_browser', is_flag=True, default=False, show_default=True,
|
||||||
|
help='Open the web browser after launch.')
|
||||||
|
@click.option('--port', '-p', help="Port to run server on.", metavar='', default=5005, 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('--listen-all', is_flag=True, default=False, show_default=True,
|
||||||
|
help='Bind to all interfaces (this makes the server accessible beyond this computer).')
|
||||||
|
@click.option('--max-category-items', default=100, metavar='', show_default=True,
|
||||||
|
help='Limits the number of categorical annotation items displayed.')
|
||||||
|
def launch(data, layout, diffexp, title, verbose, debug, obs_names, var_names,
|
||||||
|
open_browser, port, listen_all, max_category_items):
|
||||||
|
"""Launch the cellxgene data viewer.
|
||||||
|
This web app lets you explore single-cell expression data.
|
||||||
|
Data must be in a format that cellxgene expects, read the
|
||||||
|
"getting started" guide.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
> cellxgene launch example_dataset/pbmc3k.h5ad --title pbmc3k
|
||||||
|
|
||||||
|
> cellxgene launch <your data file> --title <your title>"""
|
||||||
|
|
||||||
|
# Startup message
|
||||||
|
click.echo('[cellxgene] Starting the CLI...')
|
||||||
|
|
||||||
|
# Import Flask app
|
||||||
|
from server.app.app import app
|
||||||
|
|
||||||
|
# Argument checking
|
||||||
|
name, extension = splitext(data)
|
||||||
|
if extension != '.h5ad':
|
||||||
|
raise click.FileError(basename(data), hint='file type must be .h5ad')
|
||||||
|
|
||||||
|
if debug:
|
||||||
|
verbose = True
|
||||||
|
open_browser = False
|
||||||
|
|
||||||
|
if not verbose:
|
||||||
|
sys.tracebacklimit = 0
|
||||||
|
|
||||||
|
if not title:
|
||||||
|
file_parts = splitext(basename(data))
|
||||||
|
title = file_parts[0]
|
||||||
|
|
||||||
|
if listen_all:
|
||||||
|
host = '0.0.0.0'
|
||||||
|
else:
|
||||||
|
host = '127.0.0.1'
|
||||||
|
|
||||||
|
# Setup app
|
||||||
|
cellxgene_url = f"http://{host}:{port}"
|
||||||
|
api_base = f"{cellxgene_url}/api/"
|
||||||
|
|
||||||
|
app.config.update(
|
||||||
|
DATASET_TITLE=title,
|
||||||
|
CXG_API_BASE=api_base
|
||||||
|
)
|
||||||
|
|
||||||
|
if not verbose:
|
||||||
|
log = logging.getLogger('werkzeug')
|
||||||
|
log.setLevel(logging.ERROR)
|
||||||
|
|
||||||
|
click.echo(f'[cellxgene] Loading data from {basename(data)}, this may take awhile...')
|
||||||
|
|
||||||
|
from server.app.scanpy_engine.scanpy_engine import ScanpyEngine
|
||||||
|
|
||||||
|
args = {'layout': layout, 'diffexp': diffexp, 'max_category_items': max_category_items,
|
||||||
|
'obs_names': obs_names, 'var_names': var_names}
|
||||||
|
|
||||||
|
app.data = ScanpyEngine(data, args)
|
||||||
|
|
||||||
|
if open_browser:
|
||||||
|
click.echo(f'[cellxgene] Launching! Opening your browser to {cellxgene_url} now.')
|
||||||
|
webbrowser.open(cellxgene_url)
|
||||||
|
else:
|
||||||
|
click.echo(f'[cellxgene] Launching! Please go to {cellxgene_url} in your browser.')
|
||||||
|
|
||||||
|
click.echo('[cellxgene] Type CTRL-C at any time to exit.')
|
||||||
|
|
||||||
|
app.run(host=host, debug=debug, port=port)
|
||||||
@@ -25,7 +25,7 @@ class EndPoints(unittest.TestCase):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpClass(cls):
|
def setUpClass(cls):
|
||||||
cls.ps = Popen(["cellxgene", "launch", "--no-open", "example-dataset/pbmc3k.h5ad"])
|
cls.ps = Popen(["cellxgene", "launch", "example-dataset/pbmc3k.h5ad", "--debug"])
|
||||||
session = requests.Session()
|
session = requests.Session()
|
||||||
for i in range(90):
|
for i in range(90):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -13,12 +13,8 @@ from server.app.scanpy_engine.scanpy_engine import ScanpyEngine
|
|||||||
|
|
||||||
class UtilTest(unittest.TestCase):
|
class UtilTest(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
args = argparse.Namespace()
|
args = {'layout': 'umap', 'diffexp': 'ttest', 'max_category_items': 100,
|
||||||
args.layout = "umap"
|
'obs_names': None, 'var_names': None}
|
||||||
args.diffexp = "ttest"
|
|
||||||
args.max_category_items = 100
|
|
||||||
args.obs_names = None
|
|
||||||
args.var_names = None
|
|
||||||
|
|
||||||
self.data = ScanpyEngine("example-dataset/pbmc3k.h5ad", args)
|
self.data = ScanpyEngine("example-dataset/pbmc3k.h5ad", args)
|
||||||
self.data._create_schema()
|
self.data._create_schema()
|
||||||
|
|||||||
Reference in New Issue
Block a user