diff --git a/.travis.yml b/.travis.yml index 31611535..34f583f6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -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 diff --git a/server/__main__.py b/server/__main__.py index f9e3c3dc..61a5cf7f 100644 --- a/server/__main__.py +++ b/server/__main__.py @@ -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() diff --git a/server/app/app.py b/server/app/app.py index 05a08da6..1327aa4b 100644 --- a/server/app/app.py +++ b/server/app/app.py @@ -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 [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 --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) diff --git a/server/app/driver/driver.py b/server/app/driver/driver.py index 80f8c9c6..9d7c83c3 100644 --- a/server/app/driver/driver.py +++ b/server/app/driver/driver.py @@ -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 diff --git a/server/app/scanpy_engine/scanpy_engine.py b/server/app/scanpy_engine/scanpy_engine.py index b52b0742..7d2937a6 100644 --- a/server/app/scanpy_engine/scanpy_engine.py +++ b/server/app/scanpy_engine/scanpy_engine.py @@ -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] diff --git a/server/cli/__init__.py b/server/cli/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/cli/cli.py b/server/cli/cli.py new file mode 100644 index 00000000..0a6022ad --- /dev/null +++ b/server/cli/cli.py @@ -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) diff --git a/server/cli/launch.py b/server/cli/launch.py new file mode 100644 index 00000000..7390531c --- /dev/null +++ b/server/cli/launch.py @@ -0,0 +1,99 @@ +import sys +import click +import logging +import webbrowser + +from os.path import splitext, basename + + +@click.command() +@click.argument('data', metavar='', 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 --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) diff --git a/server/test/test_api.py b/server/test/test_api.py index b4c69126..9210ca73 100644 --- a/server/test/test_api.py +++ b/server/test/test_api.py @@ -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: diff --git a/server/test/test_scanpy_engine.py b/server/test/test_scanpy_engine.py index 09bf0118..fcf036ac 100644 --- a/server/test/test_scanpy_engine.py +++ b/server/test/test_scanpy_engine.py @@ -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() diff --git a/setup.py b/setup.py index e316a812..1d27ad06 100644 --- a/setup.py +++ b/setup.py @@ -25,6 +25,6 @@ setup( ), entry_points={ "console_scripts": - ["cellxgene = server.app.app:main"] + ["cellxgene = server.cli.cli:cli"] } )