diff --git a/server/app/driver/driver.py b/server/app/driver/driver.py index 9d7c83c3..3fe0e6ca 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/rest_api/rest.py b/server/app/rest_api/rest.py index b80e8895..7a21b0de 100644 --- a/server/app/rest_api/rest.py +++ b/server/app/rest_api/rest.py @@ -10,7 +10,8 @@ from werkzeug.datastructures import ImmutableMultiDict from server.app.util.constants import Axis, DiffExpMode from server.app.util.filter import parse_filter, QueryStringError from server.app.util.models import FilterModel -from server.app.util.utils import FilterError, InteractiveError, MimeTypeError, PrepareError, get_mime_type +from server.app.util.utils import get_mime_type +from server.app.util.errors import MimeTypeError, FilterError, InteractiveError, PrepareError """ Sort order for routes diff --git a/server/app/scanpy_engine/scanpy_engine.py b/server/app/scanpy_engine/scanpy_engine.py index 3584c114..788181b2 100644 --- a/server/app/scanpy_engine/scanpy_engine.py +++ b/server/app/scanpy_engine/scanpy_engine.py @@ -2,12 +2,13 @@ import warnings import numpy as np from pandas import DataFrame +from pandas.core.dtypes.dtypes import CategoricalDtype import scanpy.api as sc from scipy import stats, sparse from server.app.driver.driver import CXGDriver from server.app.util.constants import Axis, DEFAULT_TOP_N, DiffExpMode -from server.app.util.utils import FilterError, InteractiveError, PrepareError +from server.app.util.errors import FilterError, InteractiveError, PrepareError, ScanpyFileError """ Sort order for methods @@ -23,9 +24,10 @@ 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._validate_data_calculations() self.cell_count = self.data.shape[0] self.gene_count = self.data.shape[1] self.layout_options = ["umap", "tsne"] @@ -39,27 +41,28 @@ class ScanpyEngine(CXGDriver): As a *critical* side-effect, ensure the indices are simple number ranges (accomplished by calling pandas.DataFrame.reset_index()) """ - if name == 'name': + if name == "name": # a noop, so skip it return ax_name = str(axis) df_axis = getattr(self.data, ax_name) if name is None: - # reset index to simple range; alias 'name' to point at the + # reset index to simple range; alias "name" to point at the # previously specified index. - df_axis = df_axis.reset_index().rename(columns={'index': 'name'}) + df_axis.reset_index(inplace=True) + df_axis.rename(inplace=True, columns={"index": "name"}) elif name in df_axis.columns: if name not in df_axis.columns: raise KeyError(f"Annotation name {name}, specified in --{ax_name}-name does not exist.") if not df_axis[name].is_unique: raise KeyError(f"Values in -{ax_name}-name must be unique. " "Please prepare data to contain unique values.") - # reset index to simple range; alias user-specified annotation to 'name' - df_axis = df_axis.reset_index(drop=True).rename(columns={name: 'name'}) + # reset index to simple range; alias user-specified annotation to "name" + df_axis.reset_index(drop=True, inplace=True) + df_axis.rename(inplace=True, columns={name: "name"}) else: raise KeyError(f"Annotation name {name}, specified in --{ax_name}_name does not exist.") - setattr(self.data, ax_name, df_axis) def _create_schema(self): self.schema = { @@ -78,9 +81,9 @@ class ScanpyEngine(CXGDriver): for ann in curr_axis: ann_schema = {"name": ann} data_kind = curr_axis[ann].dtype.kind - if data_kind == 'f': + if data_kind == "f": ann_schema["type"] = "float32" - elif data_kind in ['i', 'u']: + elif data_kind in ["i", "u"]: ann_schema["type"] = "int32" elif data_kind == "?": ann_schema["type"] = "boolean" @@ -98,7 +101,18 @@ class ScanpyEngine(CXGDriver): # Based on benchmarking, cache=True has no impact on perf. # Note: as of current scanpy/anndata release, setting backed='r' will # result in an error. https://github.com/theislab/anndata/issues/79 - return sc.read(data, cache=False) + try: + result = sc.read(data, cache=True) + except ValueError: + raise ScanpyFileError("File must be in the .h5ad format. Please read " + "https://github.com/theislab/scanpy_usage/blob/master/170505_seurat/info_h5ad.md to " + "learn more about this format. You may be able to convert your file into this format " + "using `cellxgene prepare`, please run `cellxgene prepare --help` for more " + "information.") + except Exception as e: + raise ScanpyFileError(f"Error while loading file: {e}, File must be in the .h5ad format, please check " + f"that your input and try again.") + return result @staticmethod def _top_sort(values, sort_order, top_n=None): @@ -137,14 +151,34 @@ class ScanpyEngine(CXGDriver): curr_axis = getattr(self.data, str(ax)) for ann in curr_axis: datatype = curr_axis[ann].dtype - downcast_map = {'int64': 'int32', - 'uint32': 'int32', - 'uint64': 'int32', - 'float64': 'float32', + downcast_map = {"int64": "int32", + "uint32": "int32", + "uint64": "int32", + "float64": "float32", } if datatype in downcast_map: warnings.warn(f"Scanpy annotation {ax}:{ann} is in unsupported format: {datatype}. " f"Data will be downcast to {downcast_map[datatype]}.") + if isinstance(datatype, CategoricalDtype): + category_num = len(curr_axis[ann].dtype.categories) + if category_num > 500 and category_num > self.max_category_items: + warnings.warn( + f"{str(ax).title()} annotation '{ann}' has {category_num} categories, this may be " + f"cumbersome or slow to display. We recommend setting the " + f"--max-category-items option to 500, this will hide categorical " + f"annotations with more than 500 categories in the UI") + + def _validate_data_calculations(self): + layout_key = f"X_{self.layout_method}" + try: + assert layout_key in self.data.obsm_keys() + except AssertionError: + raise PrepareError( + f"Cannot find a field with coordinates for the {self.layout_method} layout requested. A different" + f" layout may have been computed. The requested layout must be pre-calculated and saved " + f"back in the h5ad file. You can run " + f"`cellxgene prepare --layout {self.layout_method} ` " + f"to solve this problem. ") def filter_dataframe(self, filter, include_uns=False): """ @@ -232,8 +266,8 @@ class ScanpyEngine(CXGDriver): https://docs.scipy.org/doc/scipy/reference/sparse.html """ - prefer_row_access = sparse.isspmatrix_csr(data._X) or \ - sparse.isspmatrix_lil(data._X) or sparse.isspmatrix_bsr(data._X) + prefer_row_access = sparse.isspmatrix_csr(data._X) or sparse.isspmatrix_lil(data._X) \ + or sparse.isspmatrix_bsr(data._X) if prefer_row_access: # Row-major slicing if obs_selector is not None: diff --git a/server/app/util/errors.py b/server/app/util/errors.py new file mode 100644 index 00000000..f1b5cca2 --- /dev/null +++ b/server/app/util/errors.py @@ -0,0 +1,43 @@ +class FilterError(Exception): + """ + Raised when filter is malformed + """ + + def __init__(self, message): + self.message = message + + +class InteractiveError(Exception): + """ + Raised when computation would exceed interactive time + """ + + def __init__(self, message): + self.message = message + + +class MimeTypeError(Exception): + """ + Raised when incompatible MIME type selected + """ + + def __init__(self, message): + self.message = message + + +class PrepareError(Exception): + """ + Raised when data is misprepared + """ + + def __init__(self, message): + self.message = message + + +class ScanpyFileError(Exception): + """ + Raised when file loaded into scanpy is misformatted + """ + + def __init__(self, message): + self.message = message diff --git a/server/app/util/utils.py b/server/app/util/utils.py index 57452f44..a164eec1 100644 --- a/server/app/util/utils.py +++ b/server/app/util/utils.py @@ -3,6 +3,8 @@ from argparse import ArgumentTypeError from numpy import float32, integer +from server.app.util.errors import MimeTypeError + class Float32JSONEncoder(json.JSONEncoder): def default(self, obj): @@ -13,30 +15,6 @@ class Float32JSONEncoder(json.JSONEncoder): return json.JSONEncoder.default(self, obj) -class MimeTypeError(Exception): - - def __init__(self, message): - self.message = message - - -class FilterError(Exception): - - def __init__(self, message): - self.message = message - - -class InteractiveError(Exception): - - def __init__(self, message): - self.message = message - - -class PrepareError(Exception): - - def __init__(self, message): - self.message = message - - def get_mime_type(default="application/json", acceptable_types=["application/json", "text/csv"], query_param=None, header=None): mime_type = default diff --git a/server/cli/cli.py b/server/cli/cli.py index 870c6b9d..dd74aadd 100644 --- a/server/cli/cli.py +++ b/server/cli/cli.py @@ -4,8 +4,8 @@ from .launch import launch from .prepare import prepare -@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') +@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 diff --git a/server/cli/launch.py b/server/cli/launch.py index bce26982..ca5c59f7 100644 --- a/server/cli/launch.py +++ b/server/cli/launch.py @@ -1,31 +1,32 @@ import sys import click import logging +from os.path import splitext, basename import webbrowser -from os.path import splitext, basename +from server.app.util.errors import ScanpyFileError @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.') +@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. @@ -40,15 +41,15 @@ def launch(data, layout, diffexp, title, verbose, debug, obs_names, var_names, > cellxgene launch --title """ # Startup message - click.echo('[cellxgene] Starting the CLI...') + 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 extension != ".h5ad": + raise click.FileError(basename(data), hint="file type must be .h5ad") if debug: verbose = True @@ -62,9 +63,9 @@ def launch(data, layout, diffexp, title, verbose, debug, obs_names, var_names, title = file_parts[0] if listen_all: - host = '0.0.0.0' + host = "0.0.0.0" else: - host = '127.0.0.1' + host = "127.0.0.1" # Setup app cellxgene_url = f"http://{host}:{port}" @@ -76,24 +77,32 @@ def launch(data, layout, diffexp, title, verbose, debug, obs_names, var_names, ) if not verbose: - log = logging.getLogger('werkzeug') + log = logging.getLogger("werkzeug") log.setLevel(logging.ERROR) - click.echo(f'[cellxgene] Loading data from {basename(data)}, this may take awhile...') + 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} + args = { + "layout": layout, + "diffexp": diffexp, + "max_category_items": max_category_items, + "obs_names": obs_names, + "var_names": var_names + } - app.data = ScanpyEngine(data, args) + try: + app.data = ScanpyEngine(data, args) + except ScanpyFileError as e: + raise click.ClickException(f"{e}") if open_browser: - click.echo(f'[cellxgene] Launching! Opening your browser to {cellxgene_url} now.') + 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(f"[cellxgene] Launching! Please go to {cellxgene_url} in your browser.") - click.echo('[cellxgene] Type CTRL-C at any time to exit.') + click.echo("[cellxgene] Type CTRL-C at any time to exit.") app.run(host=host, debug=debug, port=port, threaded=True) diff --git a/server/cli/prepare.py b/server/cli/prepare.py index 8d5bd64e..1b0d6063 100644 --- a/server/cli/prepare.py +++ b/server/cli/prepare.py @@ -1,26 +1,26 @@ -import click +from os.path import expanduser, isdir, isfile, sep, splitext -from numpy import unique, ndarray +import click +from numpy import ndarray, unique from scipy.sparse.csc import csc_matrix -from os.path import isfile, isdir, splitext, expanduser, sep @click.command() -@click.argument('data', nargs=1, metavar='', required=True) -@click.option('--layout', '-l', default=['umap', 'tsne'], multiple=True, type=click.Choice(['umap', 'tsne']), - help='Layout algorithm', show_default=True) -@click.option('--recipe', '-r', default='none', type=click.Choice(['none', 'seurat', 'zheng17']), - help='Preprocessing to run.', show_default=True) -@click.option('--output', '-o', default='', help='Save a new file to filename.', metavar='') -@click.option('--plotting', '-p', default=False, is_flag=True, help='Whether to generate plots.', show_default=True) -@click.option('--sparse', default=False, is_flag=True, help='Whether to force sparsity.', show_default=True) -@click.option('--overwrite', default=False, is_flag=True, help='Allow file overwriting.', show_default=True) -@click.option('--set-obs-names', default='', help='Named field to set as index for obs.', metavar='') -@click.option('--set-var-names', default='', help='Named field to set as index for var.', metavar='') -@click.option('--make-obs-names-unique', default=True, is_flag=True, - help='Ensure obs index is unique.', show_default=True) -@click.option('--make-var-names-unique', default=True, is_flag=True, - help='Ensure var index is unique.', show_default=True) +@click.argument("data", nargs=1, metavar="", required=True) +@click.option("--layout", "-l", default=["umap", "tsne"], multiple=True, type=click.Choice(["umap", "tsne"]), + help="Layout algorithm", show_default=True) +@click.option("--recipe", "-r", default="none", type=click.Choice(["none", "seurat", "zheng17"]), + help="Preprocessing to run.", show_default=True) +@click.option("--output", "-o", default="", help="Save a new file to filename.", metavar="") +@click.option("--plotting", "-p", default=False, is_flag=True, help="Whether to generate plots.", show_default=True) +@click.option("--sparse", default=False, is_flag=True, help="Whether to force sparsity.", show_default=True) +@click.option("--overwrite", default=False, is_flag=True, help="Allow file overwriting.", show_default=True) +@click.option("--set-obs-names", default="", help="Named field to set as index for obs.", metavar="") +@click.option("--set-var-names", default="", help="Named field to set as index for var.", metavar="") +@click.option("--make-obs-names-unique", default=True, is_flag=True, + help="Ensure obs index is unique.", show_default=True) +@click.option("--make-var-names-unique", default=True, is_flag=True, + help="Ensure var index is unique.", show_default=True) def prepare(data, layout, recipe, output, plotting, sparse, overwrite, set_obs_names, set_var_names, make_obs_names_unique, make_var_names_unique): """Preprocesses data for use with cellxgene. @@ -33,9 +33,9 @@ def prepare(data, layout, recipe, output, plotting, sparse, overwrite, annotations, ensuring sparsity, and plotting results.""" # collect slow imports here to make CLI startup more responsive - click.echo('[cellxgene] Starting CLI...') + click.echo("[cellxgene] Starting CLI...") import matplotlib - matplotlib.use('Agg') + matplotlib.use("Agg") import scanpy.api as sc # scanpy settings @@ -43,45 +43,45 @@ def prepare(data, layout, recipe, output, plotting, sparse, overwrite, sc.settings.autosave = True # check args - if sparse and not recipe == 'none': - raise click.UsageError('Cannot use a recipe when forcing sparsity') + if sparse and not recipe == "none": + raise click.UsageError("Cannot use a recipe when forcing sparsity") output = expanduser(output) if isfile(output) and not overwrite: - raise click.UsageError(f'Cannot overwrite existing file {output}, try using the flag --overwrite') + raise click.UsageError(f"Cannot overwrite existing file {output}, try using the flag --overwrite") def load_data(data): if isfile(data): name, extension = splitext(data) - if extension == '.h5ad': + if extension == ".h5ad": adata = sc.read_h5ad(data) - elif extension == '.loom': + elif extension == ".loom": adata = sc.read_loom(data) else: - raise click.FileError(data, hint='does not have a valid extension [.h5ad | .loom]') + raise click.FileError(data, hint="does not have a valid extension [.h5ad | .loom]") elif isdir(data): if not data.endswith(sep): data += sep adata = sc.read_10x_mtx(data) else: - raise click.FileError(data, hint='not a valid file or path') + raise click.FileError(data, hint="not a valid file or path") - if not set_obs_names == '': + if not set_obs_names == "": if set_obs_names not in adata.obs_keys(): - raise click.UsageError(f'obs {set_obs_names} not found, options are: {adata.obs_keys()}') + raise click.UsageError(f"obs {set_obs_names} not found, options are: {adata.obs_keys()}") adata.obs_names = adata.obs[set_obs_names] - if not set_var_names == '': + if not set_var_names == "": if set_var_names not in adata.var_keys(): - raise click.UsageError(f'var {set_var_names} not found, options are: {adata.var_keys()}') + raise click.UsageError(f"var {set_var_names} not found, options are: {adata.var_keys()}") adata.var_names = adata.var[set_var_names] if make_obs_names_unique: adata.obs_names_make_unique() if make_var_names_unique: adata.var_names_make_unique() if not adata._obs.index.is_unique: - click.echo('Warning: obs index is not unique') + click.echo("Warning: obs index is not unique") if not adata._var.index.is_unique: - click.echo('Warning: var index is not unique') + click.echo("Warning: var index is not unique") return adata @@ -90,9 +90,9 @@ def prepare(data, layout, recipe, output, plotting, sparse, overwrite, adata.X = csc_matrix(adata.X) def run_recipe(adata): - if recipe == 'seurat': + if recipe == "seurat": sc.pp.recipe_seurat(adata) - elif recipe == 'zheng17': + elif recipe == "zheng17": sc.pp.recipe_zheng17(adata) else: sc.pp.filter_cells(adata, min_genes=5) @@ -104,9 +104,9 @@ def prepare(data, layout, recipe, output, plotting, sparse, overwrite, def run_pca(adata): if sparse: - sc.pp.pca(adata, svd_solver='arpack', zero_center=False) + sc.pp.pca(adata, svd_solver="arpack", zero_center=False) else: - sc.pp.pca(adata, svd_solver='arpack') + sc.pp.pca(adata, svd_solver="arpack") def run_neighbors(adata): sc.pp.neighbors(adata) @@ -115,46 +115,46 @@ def prepare(data, layout, recipe, output, plotting, sparse, overwrite, sc.tl.louvain(adata) def run_layout(adata): - if len(unique(adata.obs['louvain'].values)) < 10: - palette = 'tab10' + if len(unique(adata.obs["louvain"].values)) < 10: + palette = "tab10" else: - palette = 'tab20' + palette = "tab20" - if 'umap' in layout: + if "umap" in layout: sc.tl.umap(adata) if plotting: - sc.pl.umap(adata, color='louvain', palette=palette, save='_louvain') + sc.pl.umap(adata, color="louvain", palette=palette, save="_louvain") - if 'tsne' in layout: + if "tsne" in layout: sc.tl.tsne(adata) if plotting: - sc.pl.tsne(adata, color='louvain', palette=palette, save='_louvain') + sc.pl.tsne(adata, color="louvain", palette=palette, save="_louvain") def show_step(item): names = { - 'make_sparse': 'Ensuring sparsity', - 'run_recipe': 'Running preprocessing recipe "%s"' % recipe, - 'run_pca': 'Running PCA', - 'run_neighbors': 'Calculating neighbors', - 'run_louvain': 'Calculating clusters', - 'run_layout': 'Computing layout' + "make_sparse": "Ensuring sparsity", + "run_recipe": f"Running preprocessing recipe \"{recipe}\"", + "run_pca": "Running PCA", + "run_neighbors": "Calculating neighbors", + "run_louvain": "Calculating clusters", + "run_layout": "Computing layout" } if item is not None: return names[item.__name__] steps = [make_sparse, run_recipe, run_pca, run_neighbors, run_louvain, run_layout] - click.echo(f'[cellxgene] Loading data from {data}, please wait...') + click.echo(f"[cellxgene] Loading data from {data}, please wait...") adata = load_data(data) - click.echo('[cellxgene] Beginning preprocessing...') - with click.progressbar(steps, label='[cellxgene] Progress', show_eta=False, item_show_func=show_step) as bar: + click.echo("[cellxgene] Beginning preprocessing...") + with click.progressbar(steps, label="[cellxgene] Progress", show_eta=False, item_show_func=show_step) as bar: for step in bar: step(adata) # saving - if not output == '': - click.echo(f'[cellxgene] Saving results to {output}...') + if not output == "": + click.echo(f"[cellxgene] Saving results to {output}...") adata.write(output) - click.echo('[cellxgene] Success!') + click.echo("[cellxgene] Success!") diff --git a/server/run.py b/server/run.py deleted file mode 100644 index dd4feb33..00000000 --- a/server/run.py +++ /dev/null @@ -1,3 +0,0 @@ -from app.app import app - -app.run(host="0.0.0.0", debug=True, port=5005) diff --git a/server/test/test_scanpy_engine.py b/server/test/test_scanpy_engine.py index fcf036ac..6861dd68 100644 --- a/server/test/test_scanpy_engine.py +++ b/server/test/test_scanpy_engine.py @@ -34,7 +34,8 @@ class UtilTest(unittest.TestCase): @pytest.mark.filterwarnings("ignore:Scanpy data matrix") def test_data_type(self): self.data.data.X = self.data.data.X.astype("float64") - self.assertWarns(UserWarning, self.data._validate_data_types()) + with self.assertWarns(UserWarning): + self.data._validate_data_types() def test_filter_idx(self): filter_ = {