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:
Jeremy Freeman
2018-11-02 10:52:50 -04:00
committed by GitHub
parent 6e46a65c32
commit 0c8a07ac13
11 changed files with 124 additions and 139 deletions

View File

@@ -14,6 +14,7 @@ install:
script:
- set -eo pipefail
- flake8 server/app/
- flake8 server/cli/
- npm run --prefix client/ build
- npm run --prefix client/ test
- pytest -s server/test

View File

@@ -8,5 +8,5 @@ if __package__ is None:
__package__ = PKG_PATH.name
# Main thing
from .app.app import main
main()
from .cli.cli import cli
cli()

View File

@@ -1,8 +1,4 @@
import argparse
import logging
import os
import sys
import webbrowser
from flask import Flask
from flask_caching import Cache
@@ -11,7 +7,7 @@ from flask_cors import CORS
from flask_restful_swagger_2 import get_swagger_blueprint
from .rest_api.rest import get_api_resources
from .util.utils import Float32JSONEncoder, whole_number
from .util.utils import Float32JSONEncoder
from .web import webapp
REACTIVE_LIMIT = 1_000_000
@@ -32,7 +28,6 @@ app.config.update(
# Application Data
data = None
# A list of swagger document objects
docs = []
resources = get_api_resources()
@@ -45,121 +40,3 @@ app.register_blueprint(
description="An API connecting ExpressionMatrix2 clustering algorithm to cellxgene"))
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)

View File

@@ -14,9 +14,9 @@ class CXGDriver(metaclass=ABCMeta):
def __init__(self, data, args):
self.data = self._load_data(data)
self.layout_method = args.layout
self.diffexp_method = args.diffexp
self.max_category_items = args.max_category_items
self.layout_method = args['layout']
self.diffexp_method = args['diffexp']
self.max_category_items = args['max_category_items']
self.cluster = None
@property

View File

@@ -24,8 +24,8 @@ class ScanpyEngine(CXGDriver):
def __init__(self, data, args):
super().__init__(data, args)
self._alias_annotation_names(Axis.OBS, args.obs_names)
self._alias_annotation_names(Axis.VAR, args.var_names)
self._alias_annotation_names(Axis.OBS, args['obs_names'])
self._alias_annotation_names(Axis.VAR, args['var_names'])
self._validate_data_types()
self.cell_count = self.data.shape[0]
self.gene_count = self.data.shape[1]

0
server/cli/__init__.py Normal file
View File

12
server/cli/cli.py Normal file
View File

@@ -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)

99
server/cli/launch.py Normal file
View File

@@ -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)

View File

@@ -25,7 +25,7 @@ class EndPoints(unittest.TestCase):
@classmethod
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()
for i in range(90):
try:

View File

@@ -13,12 +13,8 @@ from server.app.scanpy_engine.scanpy_engine import ScanpyEngine
class UtilTest(unittest.TestCase):
def setUp(self):
args = argparse.Namespace()
args.layout = "umap"
args.diffexp = "ttest"
args.max_category_items = 100
args.obs_names = None
args.var_names = None
args = {'layout': 'umap', 'diffexp': 'ttest', 'max_category_items': 100,
'obs_names': None, 'var_names': None}
self.data = ScanpyEngine("example-dataset/pbmc3k.h5ad", args)
self.data._create_schema()

View File

@@ -25,6 +25,6 @@ setup(
),
entry_points={
"console_scripts":
["cellxgene = server.app.app:main"]
["cellxgene = server.cli.cli:cli"]
}
)