From f0f7200f0b750ceed3f3b24c3c15b17478c56bf3 Mon Sep 17 00:00:00 2001 From: Charlotte Weaver Date: Tue, 18 Dec 2018 21:14:24 -0800 Subject: [PATCH] Move jsonification to engine level (#511) --- server/app/rest_api/rest.py | 344 +++++++++++++++++----- server/app/scanpy_engine/scanpy_engine.py | 140 +++++++-- server/app/util/constants.py | 7 +- server/app/util/errors.py | 9 + server/app/util/utils.py | 4 + server/test/test_datasets/nan.h5ad | Bin 0 -> 70782 bytes server/test/test_nan_rest.py | 96 ++++++ server/test/test_nan_scanpy_engine.py | 85 ++++++ server/test/test_scanpy_engine.py | 103 +++++-- setup.cfg | 2 +- 10 files changed, 657 insertions(+), 133 deletions(-) create mode 100644 server/test/test_datasets/nan.h5ad create mode 100644 server/test/test_nan_rest.py create mode 100644 server/test/test_nan_scanpy_engine.py diff --git a/server/app/rest_api/rest.py b/server/app/rest_api/rest.py index e20fb340..28350297 100644 --- a/server/app/rest_api/rest.py +++ b/server/app/rest_api/rest.py @@ -6,11 +6,22 @@ from flask import Blueprint, current_app, jsonify, make_response, request from flask_restful_swagger_2 import Api, swagger, Resource from werkzeug.datastructures import ImmutableMultiDict -from server.app.util.constants import Axis, DiffExpMode, JSON_NaN_to_num_warning_msg +from server.app.util.constants import ( + Axis, + DiffExpMode, + JSON_MIMETYPE, + JSON_NaN_to_num_warning_msg, +) from server.app.util.filter import parse_filter, QueryStringError from server.app.util.models import FilterModel from server.app.util.utils import get_mime_type -from server.app.util.errors import MimeTypeError, FilterError, InteractiveError, PrepareError +from server.app.util.errors import ( + FilterError, + InteractiveError, + JSONEncodingValueError, + MimeTypeError, + PrepareError, +) """ Sort order for routes @@ -32,7 +43,11 @@ class SchemaAPI(Resource): "examples": { "application/json": { "schema": { - "dataframe": {"nObs": 383, "nVar": 19944, "type": "float32"}, + "dataframe": { + "nObs": 383, + "nVar": 19944, + "type": "float32", + }, "annotations": { "obs": [ {"name": "name", "type": "string"}, @@ -46,7 +61,10 @@ class SchemaAPI(Resource): }, {"name": "QScore", "type": "float32"}, ], - "var": [{"name": "name", "type": "string"}, {"name": "gene", "type": "string"}], + "var": [ + {"name": "name", "type": "string"}, + {"name": "gene", "type": "string"}, + ], }, } } @@ -56,7 +74,9 @@ class SchemaAPI(Resource): } ) def get(self): - return make_response(jsonify({"schema": current_app.data.schema}), HTTPStatus.OK) + return make_response( + jsonify({"schema": current_app.data.schema}), HTTPStatus.OK + ) class ConfigAPI(Resource): @@ -73,14 +93,22 @@ class ConfigAPI(Resource): "application/json": { "config": { "features": [ - {"method": "POST", "path": "/cluster/", "available": False}, + { + "method": "POST", + "path": "/cluster/", + "available": False, + }, { "method": "POST", "path": "/layout/obs", "available": True, "interactiveLimit": 10000, }, - {"method": "POST", "path": "/layout/var", "available": False}, + { + "method": "POST", + "path": "/layout/var", + "available": False, + }, ], "displayNames": { "engine": "ScanPy version 1.33", @@ -97,16 +125,34 @@ class ConfigAPI(Resource): config = { "config": { "features": [ - {"method": "POST", "path": "/cluster/", **current_app.data.features["cluster"]}, - {"method": "POST", "path": "/layout/obs", **current_app.data.features["layout"]["obs"]}, - {"method": "POST", "path": "/layout/var", **current_app.data.features["layout"]["var"]}, - {"method": "POST", "path": "/diffexp/", **current_app.data.features["diffexp"]}, + { + "method": "POST", + "path": "/cluster/", + **current_app.data.features["cluster"], + }, + { + "method": "POST", + "path": "/layout/obs", + **current_app.data.features["layout"]["obs"], + }, + { + "method": "POST", + "path": "/layout/var", + **current_app.data.features["layout"]["var"], + }, + { + "method": "POST", + "path": "/diffexp/", + **current_app.data.features["diffexp"], + }, ], "displayNames": { "engine": f"cellxgene Scanpy engine version {pkg_resources.get_distribution('cellxgene').version}", "dataset": current_app.config["DATASET_TITLE"], }, - "parameters": {"max_category_items": current_app.data.max_category_items}, + "parameters": { + "max_category_items": current_app.data.max_category_items + }, } } return make_response(jsonify(config), HTTPStatus.OK) @@ -150,14 +196,17 @@ class AnnotationsObsAPI(Resource): fields = request.args.getlist("annotation-name", None) try: annotation_response = current_app.data.annotation({}, "obs", fields) + return make_response( + annotation_response, HTTPStatus.OK, {"Content-Type": JSON_MIMETYPE} + ) except KeyError: return make_response(f"Error bad key in {fields}", HTTPStatus.BAD_REQUEST) - try: - return make_response(jsonify(annotation_response), HTTPStatus.OK) - except ValueError as e: + except JSONEncodingValueError as e: # JSON encoding failure, usually due to bad data warnings.warn(JSON_NaN_to_num_warning_msg) return make_response(str(e), HTTPStatus.INTERNAL_SERVER_ERROR) + except ValueError as e: + return make_response(str(e), HTTPStatus.INTERNAL_SERVER_ERROR) @swagger.doc( { @@ -170,7 +219,12 @@ class AnnotationsObsAPI(Resource): "type": "string", "description": "list of 1 or more annotation names", }, - {"name": "filter", "description": "Complex Filter", "in": "body", "schema": FilterModel}, + { + "name": "filter", + "description": "Complex Filter", + "in": "body", + "schema": FilterModel, + }, ], "responses": { "200": { @@ -196,17 +250,22 @@ class AnnotationsObsAPI(Resource): def put(self): fields = request.args.getlist("annotation-name", None) try: - annotation_response = current_app.data.annotation(request.get_json()["filter"], "obs", fields) + annotation_response = current_app.data.annotation( + request.get_json()["filter"], "obs", fields + ) + return make_response( + annotation_response, HTTPStatus.OK, {"Content-Type": JSON_MIMETYPE} + ) except KeyError: return make_response(f"Error bad key in {fields}", HTTPStatus.BAD_REQUEST) except FilterError as e: return make_response(e.message, HTTPStatus.BAD_REQUEST) - try: - return make_response(jsonify(annotation_response), HTTPStatus.OK) - except ValueError as e: + except JSONEncodingValueError as e: # JSON encoding failure, usually due to bad data warnings.warn(JSON_NaN_to_num_warning_msg) return make_response(str(e), HTTPStatus.INTERNAL_SERVER_ERROR) + except ValueError as e: + return make_response(str(e), HTTPStatus.INTERNAL_SERVER_ERROR) class AnnotationsVarAPI(Resource): @@ -228,7 +287,11 @@ class AnnotationsVarAPI(Resource): "examples": { "application/json": { "names": ["name", "category"], - "data": [[0, "ATAD3C", 1], [1, "RER1", None], [49, "S100B", 6]], + "data": [ + [0, "ATAD3C", 1], + [1, "RER1", None], + [49, "S100B", 6], + ], } }, }, @@ -243,14 +306,17 @@ class AnnotationsVarAPI(Resource): fields = request.args.getlist("annotation-name", None) try: annotation_response = current_app.data.annotation({}, "var", fields) + return make_response( + annotation_response, HTTPStatus.OK, {"Content-Type": JSON_MIMETYPE} + ) except KeyError: return make_response(f"Error bad key in {fields}", HTTPStatus.BAD_REQUEST) - try: - return make_response(jsonify(annotation_response), HTTPStatus.OK) - except ValueError as e: + except JSONEncodingValueError as e: # JSON encoding failure, usually due to bad data warnings.warn(JSON_NaN_to_num_warning_msg) return make_response(str(e), HTTPStatus.INTERNAL_SERVER_ERROR) + except ValueError as e: + return make_response(str(e), HTTPStatus.INTERNAL_SERVER_ERROR) @swagger.doc( { @@ -263,7 +329,12 @@ class AnnotationsVarAPI(Resource): "type": "string", "description": "list of 1 or more annotation names", }, - {"name": "filter", "description": "Complex Filter", "in": "body", "schema": FilterModel}, + { + "name": "filter", + "description": "Complex Filter", + "in": "body", + "schema": FilterModel, + }, ], "responses": { "200": { @@ -271,7 +342,11 @@ class AnnotationsVarAPI(Resource): "examples": { "application/json": { "names": ["name", "category"], - "data": [[0, "ATAD3C", 1], [1, "RER1", None], [49, "S100B", 6]], + "data": [ + [0, "ATAD3C", 1], + [1, "RER1", None], + [49, "S100B", 6], + ], } }, }, @@ -285,17 +360,22 @@ class AnnotationsVarAPI(Resource): def put(self): fields = request.args.getlist("annotation-name", None) try: - annotation_response = current_app.data.annotation(request.get_json()["filter"], "var", fields) + annotation_response = current_app.data.annotation( + request.get_json()["filter"], "var", fields + ) + return make_response( + annotation_response, HTTPStatus.OK, {"Content-Type": JSON_MIMETYPE} + ) except KeyError: return make_response(f"Error bad key in {fields}", HTTPStatus.BAD_REQUEST) except FilterError: return make_response("Malformed filter", HTTPStatus.BAD_REQUEST) - try: - return make_response(jsonify(annotation_response), HTTPStatus.OK) - except ValueError as e: + except JSONEncodingValueError as e: # JSON encoding failure, usually due to bad data warnings.warn(JSON_NaN_to_num_warning_msg) return make_response(str(e), HTTPStatus.INTERNAL_SERVER_ERROR) + except ValueError as e: + return make_response(str(e), HTTPStatus.INTERNAL_SERVER_ERROR) class DataObsAPI(Resource): @@ -304,13 +384,28 @@ class DataObsAPI(Resource): "summary": "Get data (expression values) from the dataframe.", "tags": ["data"], "parameters": [ - {"in": "query", "name": "filter", "type": "string", "description": "axis:key:value"}, - {"in": "query", "name": "accept-type", "type": "string", "description": "MIME type"}, + { + "in": "query", + "name": "filter", + "type": "string", + "description": "axis:key:value", + }, + { + "in": "query", + "name": "accept-type", + "type": "string", + "description": "MIME type", + }, ], "responses": { "200": { "description": "expression", - "examples": {"application/json": {"var": [0, 20000], "obs": [[1, 39483, 3902, 203, 0, 0, 28]]}}, + "examples": { + "application/json": { + "var": [0, 20000], + "obs": [[1, 39483, 3902, 203, 0, 0, 28]], + } + }, }, "400": {"description": "Malformed filter"}, "406": {"description": "Unacceptable MIME type"}, @@ -323,35 +418,57 @@ class DataObsAPI(Resource): args = request.args.copy() args.pop("accept-type", None) try: - filter_ = parse_filter(ImmutableMultiDict(args), current_app.data.schema["annotations"]) + filter_ = parse_filter( + ImmutableMultiDict(args), current_app.data.schema["annotations"] + ) except QueryStringError as e: return make_response(e.message, HTTPStatus.BAD_REQUEST) # TODO support CSV try: # TODO store mime_type when more than one is supported get_mime_type( - acceptable_types=["application/json"], query_param=accept_type, header=request.accept_mimetypes + acceptable_types=["application/json"], + query_param=accept_type, + header=request.accept_mimetypes, ) except MimeTypeError as e: return make_response(e.message, HTTPStatus.NOT_ACCEPTABLE) try: - return make_response((jsonify(current_app.data.data_frame(filter_, axis=Axis.OBS))), HTTPStatus.OK) + return make_response( + current_app.data.data_frame(filter_, axis=Axis.OBS), + HTTPStatus.OK, + {"Content-Type": JSON_MIMETYPE}, + ) except FilterError as e: return make_response(e.message, HTTPStatus.BAD_REQUEST) - except ValueError as e: + except JSONEncodingValueError as e: # JSON encoding failure, usually due to bad data warnings.warn(JSON_NaN_to_num_warning_msg) return make_response(str(e), HTTPStatus.INTERNAL_SERVER_ERROR) + except ValueError as e: + return make_response(str(e), HTTPStatus.INTERNAL_SERVER_ERROR) @swagger.doc( { "summary": "Get data (expression values) from the dataframe.", "tags": ["data"], - "parameters": [{"name": "filter", "description": "Complex Filter", "in": "body", "schema": FilterModel}], + "parameters": [ + { + "name": "filter", + "description": "Complex Filter", + "in": "body", + "schema": FilterModel, + } + ], "responses": { "200": { "description": "expression", - "examples": {"application/json": {"var": [0, 20000], "obs": [[1, 39483, 3902, 203, 0, 0, 28]]}}, + "examples": { + "application/json": { + "var": [0, 20000], + "obs": [[1, 39483, 3902, 203, 0, 0, 28]], + } + }, }, "400": {"description": "Malformed filter"}, "406": {"description": "Unacceptable MIME type"}, @@ -360,21 +477,34 @@ class DataObsAPI(Resource): ) def put(self): if not request.accept_mimetypes.best_match(["application/json", "text/csv"]): - return make_response(f"Unsupported MIME type '{request.accept_mimetypes}'", HTTPStatus.NOT_ACCEPTABLE) + return make_response( + f"Unsupported MIME type '{request.accept_mimetypes}'", + HTTPStatus.NOT_ACCEPTABLE, + ) try: - get_mime_type(acceptable_types=["application/json"], header=request.accept_mimetypes) + get_mime_type( + acceptable_types=["application/json"], header=request.accept_mimetypes + ) except MimeTypeError as e: return make_response(e.message, HTTPStatus.NOT_ACCEPTABLE) try: return make_response( - (jsonify(current_app.data.data_frame(request.get_json()["filter"], axis=Axis.OBS))), HTTPStatus.OK + ( + current_app.data.data_frame( + request.get_json()["filter"], axis=Axis.OBS + ) + ), + HTTPStatus.OK, + {"Content-Type": JSON_MIMETYPE}, ) except FilterError as e: return make_response(e.message, HTTPStatus.BAD_REQUEST) - except ValueError as e: + except JSONEncodingValueError as e: # JSON encoding failure, usually due to bad data warnings.warn(JSON_NaN_to_num_warning_msg) return make_response(str(e), HTTPStatus.INTERNAL_SERVER_ERROR) + except ValueError as e: + return make_response(str(e), HTTPStatus.INTERNAL_SERVER_ERROR) class DataVarAPI(Resource): @@ -383,13 +513,28 @@ class DataVarAPI(Resource): "summary": "Get data (expression values) from the dataframe.", "tags": ["data"], "parameters": [ - {"in": "query", "name": "filter", "type": "string", "description": "axis:key:value"}, - {"in": "query", "name": "accept-type", "type": "string", "description": "MIME type"}, + { + "in": "query", + "name": "filter", + "type": "string", + "description": "axis:key:value", + }, + { + "in": "query", + "name": "accept-type", + "type": "string", + "description": "MIME type", + }, ], "responses": { "200": { "description": "expression", - "examples": {"application/json": {"obs": [0, 20000], "var": [[1, 39483, 3902, 203, 0, 0, 28]]}}, + "examples": { + "application/json": { + "obs": [0, 20000], + "var": [[1, 39483, 3902, 203, 0, 0, 28]], + } + }, }, "400": {"description": "Malformed filter"}, "406": {"description": "Unacceptable MIME type"}, @@ -402,33 +547,55 @@ class DataVarAPI(Resource): args = request.args.copy() args.pop("accept-type", None) try: - filter_ = parse_filter(ImmutableMultiDict(args), current_app.data.schema["annotations"]) + filter_ = parse_filter( + ImmutableMultiDict(args), current_app.data.schema["annotations"] + ) except QueryStringError as e: return make_response(e.message, HTTPStatus.BAD_REQUEST) try: get_mime_type( - acceptable_types=["application/json"], query_param=accept_type, header=request.accept_mimetypes + acceptable_types=["application/json"], + query_param=accept_type, + header=request.accept_mimetypes, ) except MimeTypeError as e: return make_response(e.message, HTTPStatus.NOT_ACCEPTABLE) try: - return make_response((jsonify(current_app.data.data_frame(filter_, axis=Axis.VAR))), HTTPStatus.OK) + return make_response( + current_app.data.data_frame(filter_, axis=Axis.VAR), + HTTPStatus.OK, + {"Content-Type": JSON_MIMETYPE}, + ) except FilterError as e: return make_response(e.message, HTTPStatus.BAD_REQUEST) - except ValueError as e: + except JSONEncodingValueError as e: # JSON encoding failure, usually due to bad data warnings.warn(JSON_NaN_to_num_warning_msg) return make_response(str(e), HTTPStatus.INTERNAL_SERVER_ERROR) + except ValueError as e: + return make_response(str(e), HTTPStatus.INTERNAL_SERVER_ERROR) @swagger.doc( { "summary": "Get data (expression values) from the dataframe.", "tags": ["data"], - "parameters": [{"name": "filter", "description": "Complex Filter", "in": "body", "schema": FilterModel}], + "parameters": [ + { + "name": "filter", + "description": "Complex Filter", + "in": "body", + "schema": FilterModel, + } + ], "responses": { "200": { "description": "expression", - "examples": {"application/json": {"obs": [0, 20000], "var": [[1, 39483, 3902, 203, 0, 0, 28]]}}, + "examples": { + "application/json": { + "obs": [0, 20000], + "var": [[1, 39483, 3902, 203, 0, 0, 28]], + } + }, }, "400": {"description": "Malformed filter"}, "406": {"description": "Unacceptable MIME type"}, @@ -437,22 +604,35 @@ class DataVarAPI(Resource): ) def put(self): if not request.accept_mimetypes.best_match(["application/json", "text/csv"]): - return make_response(f"Unsupported MIME type '{request.accept_mimetypes}'", HTTPStatus.NOT_ACCEPTABLE) + return make_response( + f"Unsupported MIME type '{request.accept_mimetypes}'", + HTTPStatus.NOT_ACCEPTABLE, + ) # TODO support CSV try: - get_mime_type(acceptable_types=["application/json"], header=request.accept_mimetypes) + get_mime_type( + acceptable_types=["application/json"], header=request.accept_mimetypes + ) except MimeTypeError as e: return make_response(e.message, HTTPStatus.NOT_ACCEPTABLE) try: return make_response( - (jsonify(current_app.data.data_frame(request.get_json()["filter"], axis=Axis.VAR))), HTTPStatus.OK + ( + current_app.data.data_frame( + request.get_json()["filter"], axis=Axis.VAR + ) + ), + HTTPStatus.OK, + {"Content-Type": JSON_MIMETYPE}, ) except FilterError as e: return make_response(e.message, HTTPStatus.BAD_REQUEST) - except ValueError as e: + except JSONEncodingValueError as e: # JSON encoding failure, usually due to bad data warnings.warn(JSON_NaN_to_num_warning_msg) return make_response(str(e), HTTPStatus.INTERNAL_SERVER_ERROR) + except ValueError as e: + return make_response(str(e), HTTPStatus.INTERNAL_SERVER_ERROR) class DiffExpObsAPI(Resource): @@ -522,23 +702,35 @@ class DiffExpObsAPI(Resource): except KeyError: return make_response("Error: mode is required", HTTPStatus.BAD_REQUEST) except ValueError: - return make_response(f"Error: invalid mode option {args['mode']}", HTTPStatus.BAD_REQUEST) + return make_response( + f"Error: invalid mode option {args['mode']}", HTTPStatus.BAD_REQUEST + ) # Validate filters if mode == DiffExpMode.VAR_FILTER or "varFilter" in args: # not NOT_IMPLEMENTED - return make_response("mode=varfilter not implemented", HTTPStatus.NOT_IMPLEMENTED) + return make_response( + "mode=varfilter not implemented", HTTPStatus.NOT_IMPLEMENTED + ) if mode == DiffExpMode.TOP_N and "count" not in args: - return make_response("mode=topN requires a count parameter", HTTPStatus.BAD_REQUEST) + return make_response( + "mode=topN requires a count parameter", HTTPStatus.BAD_REQUEST + ) if "set1" not in args: return make_response("set1 is required.", HTTPStatus.BAD_REQUEST) if Axis.VAR in args["set1"]["filter"]: - return make_response("Var filter not allowed for set1", HTTPStatus.BAD_REQUEST) + return make_response( + "Var filter not allowed for set1", HTTPStatus.BAD_REQUEST + ) # set2 if "set2" not in args: - return make_response("Set2 as inverse of set1 is not implemented", HTTPStatus.NOT_IMPLEMENTED) + return make_response( + "Set2 as inverse of set1 is not implemented", HTTPStatus.NOT_IMPLEMENTED + ) if Axis.VAR in args["set2"]["filter"]: - return make_response("Var filter not allowed for set2", HTTPStatus.BAD_REQUEST) + return make_response( + "Var filter not allowed for set2", HTTPStatus.BAD_REQUEST + ) set1_filter = args["set1"]["filter"] set2_filter = args.get("set2", {"filter": {}})["filter"] @@ -549,18 +741,24 @@ class DiffExpObsAPI(Resource): count = args.get("count", None) try: diffexp = current_app.data.diffexp_topN( - set1_filter, set2_filter, count, current_app.data.features["diffexp"]["interactiveLimit"] + set1_filter, + set2_filter, + count, + current_app.data.features["diffexp"]["interactiveLimit"], + ) + return make_response( + diffexp, HTTPStatus.OK, {"Content-Type": JSON_MIMETYPE} ) except (ValueError, FilterError) as e: return make_response(e.message, HTTPStatus.BAD_REQUEST) except InteractiveError: return make_response("Non-interactive request", HTTPStatus.FORBIDDEN) - try: - return make_response(jsonify(diffexp), HTTPStatus.OK) - except ValueError as e: + except JSONEncodingValueError as e: # JSON encoding failure, usually due to bad data warnings.warn(JSON_NaN_to_num_warning_msg) return make_response(str(e), HTTPStatus.INTERNAL_SERVER_ERROR) + except ValueError as e: + return make_response(str(e), HTTPStatus.INTERNAL_SERVER_ERROR) class LayoutObsAPI(Resource): @@ -576,7 +774,10 @@ class LayoutObsAPI(Resource): "application/json": { "layout": { "ndims": 2, - "coordinates": [[0, 0.284_483, 0.983_744], [1, 0.038_844, 0.739_444]], + "coordinates": [ + [0, 0.284_483, 0.983_744], + [1, 0.038_844, 0.739_444], + ], } } }, @@ -586,16 +787,19 @@ class LayoutObsAPI(Resource): } ) def get(self): + content_type = JSON_MIMETYPE try: layout = current_app.data.layout({}) except PrepareError as e: return make_response(e.message, HTTPStatus.INTERNAL_SERVER_ERROR) try: - return make_response((jsonify({"layout": layout})), HTTPStatus.OK) - except ValueError as e: + return make_response(layout, HTTPStatus.OK, {"Content-Type": content_type}) + except JSONEncodingValueError as e: # JSON encoding failure, usually due to bad data warnings.warn(JSON_NaN_to_num_warning_msg) return make_response(str(e), HTTPStatus.INTERNAL_SERVER_ERROR) + except ValueError as e: + return make_response(str(e), HTTPStatus.INTERNAL_SERVER_ERROR) # @swagger.doc({ # "summary": "Observation layout for filtered subset.", @@ -636,7 +840,7 @@ class LayoutObsAPI(Resource): # filter = request.get_json()["filter"] # interactive_limit = current_app.data.features["layout"]["obs"]["interactiveLimit"] # layout = current_app.data.layout(filter, interactive_limit=interactive_limit) - # return make_response(jsonify({"layout": layout}), HTTPStatus.OK) + # return make_response(layout, HTTPStatus.OK, {"Content-Type": content_type}) # except FilterError as e: # return make_response(e.message, HTTPStatus.BAD_REQUEST) # except InteractiveError: diff --git a/server/app/scanpy_engine/scanpy_engine.py b/server/app/scanpy_engine/scanpy_engine.py index 2534a750..bad9a9f3 100644 --- a/server/app/scanpy_engine/scanpy_engine.py +++ b/server/app/scanpy_engine/scanpy_engine.py @@ -8,7 +8,14 @@ from scipy import sparse from server.app.driver.driver import CXGDriver from server.app.util.constants import Axis, DEFAULT_TOP_N -from server.app.util.errors import FilterError, InteractiveError, PrepareError, ScanpyFileError +from server.app.util.errors import ( + FilterError, + InteractiveError, + JSONEncodingValueError, + PrepareError, + ScanpyFileError, +) +from server.app.util.utils import jsonify_scanpy from server.app.scanpy_engine.diffexp import diffexp_ttest """ @@ -58,22 +65,29 @@ class ScanpyEngine(CXGDriver): 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.") + 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." + 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.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.") + raise KeyError( + f"Annotation name {name}, specified in --{ax_name}_name does not exist." + ) @staticmethod def _can_cast_to_float32(ann): if ann.dtype.kind == "f": if not np.can_cast(ann.dtype, np.float32): - warnings.warn(f"Annotation {ann.name} will be converted to 32 bit float and may lose precision.") + warnings.warn( + f"Annotation {ann.name} will be converted to 32 bit float and may lose precision." + ) return True return False @@ -89,7 +103,11 @@ class ScanpyEngine(CXGDriver): def _create_schema(self): self.schema = { - "dataframe": {"nObs": self.cell_count, "nVar": self.gene_count, "type": str(self.data.X.dtype)}, + "dataframe": { + "nObs": self.cell_count, + "nVar": self.gene_count, + "type": str(self.data.X.dtype), + }, "annotations": {"obs": [], "var": []}, } for ax in Axis: @@ -111,7 +129,9 @@ class ScanpyEngine(CXGDriver): ann_schema["type"] = "categorical" ann_schema["categories"] = curr_axis[ann].dtype.categories.tolist() else: - raise TypeError(f"Annotations of type {curr_axis[ann].dtype} are unsupported by cellxgene.") + raise TypeError( + f"Annotations of type {curr_axis[ann].dtype} are unsupported by cellxgene." + ) self.schema["annotations"][ax].append(ann_schema) @staticmethod @@ -139,13 +159,19 @@ class ScanpyEngine(CXGDriver): def _validate_data_types(self): if self.data.X.dtype != "float32": warnings.warn( - f"Scanpy data matrix is in {self.data.X.dtype} format not float32. " f"Precision may be truncated." + f"Scanpy data matrix is in {self.data.X.dtype} format not float32. " + f"Precision may be truncated." ) for ax in Axis: 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}. " @@ -198,8 +224,12 @@ class ScanpyEngine(CXGDriver): finite_idx = np.isfinite(curr_axis[ann]) if not finite_idx.all(): curr_axis.loc[np.isnan(curr_axis[ann]), ann] = 0 - curr_axis.loc[np.isneginf(curr_axis[ann]), ann] = curr_axis[ann][finite_idx].min() - curr_axis.loc[np.isposinf(curr_axis[ann]), ann] = curr_axis[ann][finite_idx].max() + curr_axis.loc[np.isneginf(curr_axis[ann]), ann] = curr_axis[ + ann + ][finite_idx].min() + curr_axis.loc[np.isposinf(curr_axis[ann]), ann] = curr_axis[ + ann + ][finite_idx].max() warnings.warn( f"{str(ax).title()} annotation '{ann}' contains floating point NaN or Infinities. " f"These will be converted to finite values." @@ -231,7 +261,8 @@ class ScanpyEngine(CXGDriver): if non_finite_X_found: warnings.warn( - "Dataframe X contains floating point NaN or Infinities. " "These will be converted to finite values." + "Dataframe X contains floating point NaN or Infinities. " + "These will be converted to finite values." ) def filter_dataframe(self, filter): @@ -283,10 +314,15 @@ class ScanpyEngine(CXGDriver): def _axis_filter_to_mask(filter, d_axis, count): mask = np.ones((count,), dtype=bool) if "index" in filter: - mask = np.logical_and(mask, ScanpyEngine._index_filter_to_mask(filter["index"], count)) + mask = np.logical_and( + mask, ScanpyEngine._index_filter_to_mask(filter["index"], count) + ) if "annotation_value" in filter: mask = np.logical_and( - mask, ScanpyEngine._annotation_filter_to_mask(filter["annotation_value"], d_axis, count) + mask, + ScanpyEngine._annotation_filter_to_mask( + filter["annotation_value"], d_axis, count + ), ) return mask @@ -300,9 +336,13 @@ class ScanpyEngine(CXGDriver): if filter is not None: if Axis.OBS in filter: - obs_selector = self._axis_filter_to_mask(filter["obs"], self.data.obs, self.data.n_obs) + obs_selector = self._axis_filter_to_mask( + filter["obs"], self.data.obs, self.data.n_obs + ) if Axis.VAR in filter: - var_selector = self._axis_filter_to_mask(filter["var"], self.data.var, self.data.n_vars) + var_selector = self._axis_filter_to_mask( + filter["var"], self.data.var, self.data.n_vars + ) return obs_selector, var_selector @staticmethod @@ -318,7 +358,9 @@ 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) + sparse.isspmatrix_csr(data._X) + or sparse.isspmatrix_lil(data._X) + or sparse.isspmatrix_bsr(data._X) ) if prefer_row_access: # Row-major slicing @@ -352,13 +394,22 @@ class ScanpyEngine(CXGDriver): obs = self.data.obs[obs_selector] if not fields: fields = obs.columns.tolist() - result = {"names": fields, "data": DataFrame(obs[fields]).to_records(index=True).tolist()} + result = { + "names": fields, + "data": DataFrame(obs[fields]).to_records(index=True).tolist(), + } else: var = self.data.var[var_selector] if not fields: fields = var.columns.tolist() - result = {"names": fields, "data": DataFrame(var[fields]).to_records(index=True).tolist()} - return result + result = { + "names": fields, + "data": DataFrame(var[fields]).to_records(index=True).tolist(), + } + try: + return jsonify_scanpy(result) + except ValueError: + raise JSONEncodingValueError("Error encoding annotations to JSON") def data_frame(self, filter, axis): """ @@ -382,27 +433,45 @@ class ScanpyEngine(CXGDriver): if axis == Axis.OBS: result = { "var": var_index_sliced.tolist(), - "obs": DataFrame(_X, index=obs_index_sliced).to_records(index=True).tolist(), + "obs": DataFrame(_X, index=obs_index_sliced) + .to_records(index=True) + .tolist(), } else: result = { "obs": obs_index_sliced.tolist(), - "var": DataFrame(_X.T, index=var_index_sliced).to_records(index=True).tolist(), + "var": DataFrame(_X.T, index=var_index_sliced) + .to_records(index=True) + .tolist(), } - return result + try: + return jsonify_scanpy(result) + except ValueError: + raise JSONEncodingValueError("Error encoding dataframe to JSON") def diffexp_topN(self, obsFilterA, obsFilterB, top_n=None, interactive_limit=None): if Axis.VAR in obsFilterA or Axis.VAR in obsFilterB: raise FilterError("Observation filters may not contain vaiable conditions") try: - obs_mask_A = self._axis_filter_to_mask(obsFilterA["obs"], self.data.obs, self.data.n_obs) - obs_mask_B = self._axis_filter_to_mask(obsFilterB["obs"], self.data.obs, self.data.n_obs) + obs_mask_A = self._axis_filter_to_mask( + obsFilterA["obs"], self.data.obs, self.data.n_obs + ) + obs_mask_B = self._axis_filter_to_mask( + obsFilterB["obs"], self.data.obs, self.data.n_obs + ) except (KeyError, IndexError) as e: raise FilterError(f"Error parsing filter: {e}") from e if top_n is None: top_n = DEFAULT_TOP_N - result = diffexp_ttest(self.data, obs_mask_A, obs_mask_B, top_n, self.diffexp_lfc_cutoff) - return result + result = diffexp_ttest( + self.data, obs_mask_A, obs_mask_B, top_n, self.diffexp_lfc_cutoff + ) + try: + return jsonify_scanpy(result) + except ValueError: + raise JSONEncodingValueError( + "Error encoding differential expression to JSON" + ) def layout(self, filter, interactive_limit=None): """ @@ -431,6 +500,19 @@ class ScanpyEngine(CXGDriver): f"please prepare your datafile and relaunch cellxgene" ) from e normalized_layout = DataFrame( - (df_layout - df_layout.min()) / (df_layout.max() - df_layout.min()), index=df.obs.index + (df_layout - df_layout.min()) / (df_layout.max() - df_layout.min()), + index=df.obs.index, ) - return {"ndims": normalized_layout.shape[1], "coordinates": normalized_layout.to_records(index=True).tolist()} + try: + return jsonify_scanpy( + { + "layout": { + "ndims": normalized_layout.shape[1], + "coordinates": normalized_layout.to_records( + index=True + ).tolist(), + } + } + ) + except ValueError: + raise JSONEncodingValueError("Error encoding layout to JSON") diff --git a/server/app/util/constants.py b/server/app/util/constants.py index d9e5dfcb..78268ab2 100644 --- a/server/app/util/constants.py +++ b/server/app/util/constants.py @@ -3,6 +3,9 @@ from enum import Enum DEFAULT_TOP_N = 10 +# response mimetypes +JSON_MIMETYPE = "application/json" + class AugmentedEnum(Enum): def __hash__(self): @@ -27,4 +30,6 @@ class DiffExpMode(AugmentedEnum): VAR_FILTER = "varFilter" -JSON_NaN_to_num_warning_msg = "JSON encoding failure - suggest trying --nan-to-num command line option" +JSON_NaN_to_num_warning_msg = ( + "JSON encoding failure - suggest trying --nan-to-num command line option" +) diff --git a/server/app/util/errors.py b/server/app/util/errors.py index f1b5cca2..4acccdf4 100644 --- a/server/app/util/errors.py +++ b/server/app/util/errors.py @@ -16,6 +16,15 @@ class InteractiveError(Exception): self.message = message +class JSONEncodingValueError(Exception): + """ + Raised when file loaded into scanpy is misformatted + """ + + def __init__(self, message): + self.message = message + + class MimeTypeError(Exception): """ Raised when incompatible MIME type selected diff --git a/server/app/util/utils.py b/server/app/util/utils.py index f90851b0..fc274f87 100644 --- a/server/app/util/utils.py +++ b/server/app/util/utils.py @@ -54,3 +54,7 @@ def whole_number(value): if value < 0: raise ArgumentTypeError(f"{value} is not >= 0") return value + + +def jsonify_scanpy(data): + return json.dumps(data, cls=Float32JSONEncoder, allow_nan=False) diff --git a/server/test/test_datasets/nan.h5ad b/server/test/test_datasets/nan.h5ad new file mode 100644 index 0000000000000000000000000000000000000000..d97078b0e9c950dc9926d114b10ecfe391ce17ce GIT binary patch literal 70782 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 literal 0 HcmV?d00001 diff --git a/server/test/test_nan_rest.py b/server/test/test_nan_rest.py new file mode 100644 index 00000000..39d6e035 --- /dev/null +++ b/server/test/test_nan_rest.py @@ -0,0 +1,96 @@ +from http import HTTPStatus +from subprocess import Popen +import unittest +import time + +import requests + +LOCAL_URL = "http://127.0.0.1:5005/" +VERSION = "v0.2" +URL_BASE = f"{LOCAL_URL}api/{VERSION}/" + +BAD_FILTER = {"filter": {"obs": {"annotation_value": [{"name": "xyz"}]}}} + + +class WithNaNs(unittest.TestCase): + """Test Case for endpoints""" + + @classmethod + def setUpClass(cls): + cls.ps = Popen( + ["cellxgene", "launch", "server/test/test_datasets/nan.h5ad", "--debug"] + ) + session = requests.Session() + for i in range(90): + try: + session.get(f"{URL_BASE}schema") + except requests.exceptions.ConnectionError: + time.sleep(1) + + @classmethod + def tearDownClass(cls): + try: + cls.ps.terminate() + except ProcessLookupError: + pass + + def setUp(self): + self.session = requests.Session() + + def test_initialize(self): + endpoint = "schema" + url = f"{URL_BASE}{endpoint}" + result = self.session.get(url) + self.assertEqual(result.status_code, HTTPStatus.OK) + + def test_errors(self): + endpoints = ["annotations/obs", "annotations/var", "data/obs", "data/var"] + for endpoint in endpoints: + url = f"{URL_BASE}{endpoint}" + result = self.session.get(url) + self.assertEqual(result.status_code, HTTPStatus.INTERNAL_SERVER_ERROR) + + +class WithoutNaNs(unittest.TestCase): + """Test Case for endpoints""" + + @classmethod + def setUpClass(cls): + cls.ps = Popen( + [ + "cellxgene", + "launch", + "server/test/test_datasets/nan.h5ad", + "--nan-to-num", + "--debug", + ] + ) + session = requests.Session() + for i in range(90): + try: + session.get(f"{URL_BASE}schema") + except requests.exceptions.ConnectionError: + time.sleep(1) + + @classmethod + def tearDownClass(cls): + try: + cls.ps.terminate() + except ProcessLookupError: + pass + + def setUp(self): + self.session = requests.Session() + + def test_initialize(self): + endpoint = "schema" + url = f"{URL_BASE}{endpoint}" + result = self.session.get(url) + self.assertEqual(result.status_code, HTTPStatus.OK) + + def test_errors(self): + endpoints = ["annotations/obs", "annotations/var", "data/obs", "data/var"] + for endpoint in endpoints: + url = f"{URL_BASE}{endpoint}" + result = self.session.get(url) + self.assertEqual(result.status_code, HTTPStatus.OK) diff --git a/server/test/test_nan_scanpy_engine.py b/server/test/test_nan_scanpy_engine.py new file mode 100644 index 00000000..b5e23f05 --- /dev/null +++ b/server/test/test_nan_scanpy_engine.py @@ -0,0 +1,85 @@ +import json +import pytest +import unittest +import warnings + +from server.app.scanpy_engine.scanpy_engine import ScanpyEngine +from server.app.util.errors import JSONEncodingValueError + + +class NaNTest(unittest.TestCase): + def setUp(self): + self.args = { + "layout": "umap", + "diffexp": "ttest", + "max_category_items": 100, + "obs_names": None, + "var_names": None, + "diffexp_lfc_cutoff": 0.01, + "nan_to_num": False, + } + with warnings.catch_warnings(): + warnings.simplefilter("ignore", category=UserWarning) + self.data = ScanpyEngine("server/test/test_datasets/nan.h5ad", self.args) + self.data._create_schema() + self.args_nan = dict(self.args) + self.args_nan["nan_to_num"] = True + with warnings.catch_warnings(): + warnings.simplefilter("ignore", category=UserWarning) + self.data_nan = ScanpyEngine( + "server/test/test_datasets/nan.h5ad", self.args_nan + ) + self.data_nan._create_schema() + + def test_load(self): + with self.assertWarns(UserWarning): + ScanpyEngine("server/test/test_datasets/nan.h5ad", self.args_nan) + + def test_init(self): + self.assertEqual(self.data.cell_count, 100) + self.assertEqual(self.data.gene_count, 100) + epsilon = 0.000_005 + self.assertTrue(self.data.data.X[0, 0] - -0.171_469_51 < epsilon) + + self.assertEqual(self.data_nan.cell_count, 100) + self.assertEqual(self.data_nan.gene_count, 100) + epsilon = 0.000_005 + self.assertTrue(self.data_nan.data.X[0, 0] - -0.171_469_51 < epsilon) + + def test_dataframe(self): + data_frame_obs = json.loads(self.data_nan.data_frame(None, "obs")) + self.assertEqual(len(data_frame_obs["var"]), 100) + self.assertEqual(len(data_frame_obs["obs"]), 100) + data_frame_var = json.loads(self.data_nan.data_frame(None, "var")) + self.assertEqual(len(data_frame_var["var"]), 100) + self.assertEqual(len(data_frame_var["obs"]), 100) + with pytest.raises(JSONEncodingValueError): + data_frame_obs = json.loads(self.data.data_frame(None, "obs")) + with pytest.raises(JSONEncodingValueError): + data_frame_var = json.loads(self.data.data_frame(None, "var")) + + def test_dataframe_nan_to_0(self): + data_frame_obs = json.loads(self.data_nan.data_frame(None, "obs")) + self.assertEqual(data_frame_obs["obs"][1][3], 0.0) + data_frame_var = json.loads(self.data_nan.data_frame(None, "var")) + self.assertEqual(data_frame_var["var"][1][5], 0.0) + + def test_annotation_nan_to_0(self): + annotations_obs = json.loads(self.data_nan.annotation(None, "obs")) + self.assertEqual(annotations_obs["data"][0][3], 0.0) + annotations_var = json.loads(self.data_nan.annotation(None, "var")) + self.assertEqual(annotations_var["data"][0][3], 0.0) + + def test_annotation(self): + annotations = json.loads(self.data_nan.annotation(None, "obs")) + self.assertEqual( + annotations["names"], + ["name", "n_genes", "percent_mito", "n_counts", "louvain"], + ) + annotations = json.loads(self.data_nan.annotation(None, "var")) + self.assertEqual(annotations["names"], ["name", "n_cells", "var_with_nans"]) + self.assertEqual(len(annotations["data"]), 100) + with pytest.raises(JSONEncodingValueError): + annotations = json.loads(self.data.annotation(None, "obs")) + with pytest.raises(JSONEncodingValueError): + annotations = json.loads(self.data.annotation(None, "var")) diff --git a/server/test/test_scanpy_engine.py b/server/test/test_scanpy_engine.py index 7ae883a8..edc4cf1c 100644 --- a/server/test/test_scanpy_engine.py +++ b/server/test/test_scanpy_engine.py @@ -44,22 +44,39 @@ class UtilTest(unittest.TestCase): self.data._validate_data_types() def test_filter_idx(self): - filter_ = {"filter": {"var": {"index": [1, 99, [200, 300]]}, "obs": {"index": [1, 99, [1000, 2000]]}}} + filter_ = { + "filter": { + "var": {"index": [1, 99, [200, 300]]}, + "obs": {"index": [1, 99, [1000, 2000]]}, + } + } data = self.data.filter_dataframe(filter_["filter"]) self.assertEqual(data.shape, (1002, 102)) def test_filter_annotation(self): filter_ = { - "filter": {"obs": {"annotation_value": [{"name": "louvain", "values": ["NK cells", "CD8 T cells"]}]}} + "filter": { + "obs": { + "annotation_value": [ + {"name": "louvain", "values": ["NK cells", "CD8 T cells"]} + ] + } + } } data = self.data.filter_dataframe(filter_["filter"]) self.assertEqual(data.shape, (470, 1838)) - filter_ = {"filter": {"obs": {"annotation_value": [{"name": "n_counts", "min": 3000}]}}} + filter_ = { + "filter": {"obs": {"annotation_value": [{"name": "n_counts", "min": 3000}]}} + } data = self.data.filter_dataframe(filter_["filter"]) self.assertEqual(data.shape, (497, 1838)) def test_filter_annotation_no_uns(self): - filter_ = {"filter": {"var": {"annotation_value": [{"name": "name", "values": ["RER1"]}]}}} + filter_ = { + "filter": { + "var": {"annotation_value": [{"name": "name", "values": ["RER1"]}]} + } + } data = self.data.filter_dataframe(filter_["filter"]) self.assertEqual(data.shape[1], 1) @@ -90,36 +107,45 @@ class UtilTest(unittest.TestCase): def test_schema_produces_error(self): self.data.data.obs["time"] = Series( - list([time.time() for i in range(self.data.cell_count)]), dtype="datetime64[ns]" + list([time.time() for i in range(self.data.cell_count)]), + dtype="datetime64[ns]", ) with pytest.raises(TypeError): self.data._create_schema() def test_config(self): - self.assertEqual(self.data.features["layout"]["obs"], {"available": True, "interactiveLimit": 50000}) + self.assertEqual( + self.data.features["layout"]["obs"], + {"available": True, "interactiveLimit": 50000}, + ) def test_layout(self): - layout = self.data.layout(None) - self.assertEqual(layout["ndims"], 2) - self.assertEqual(len(layout["coordinates"]), 2638) - self.assertEqual(layout["coordinates"][0][0], 0) - for idx, val in enumerate(layout["coordinates"]): + layout = json.loads(self.data.layout(None)) + self.assertEqual(layout["layout"]["ndims"], 2) + self.assertEqual(len(layout["layout"]["coordinates"]), 2638) + self.assertEqual(layout["layout"]["coordinates"][0][0], 0) + for idx, val in enumerate(layout["layout"]["coordinates"]): self.assertLessEqual(val[1], 1) self.assertLessEqual(val[2], 1) def test_annotations(self): - annotations = self.data.annotation(None, "obs") - self.assertEqual(annotations["names"], ["name", "n_genes", "percent_mito", "n_counts", "louvain"]) + annotations = json.loads(self.data.annotation(None, "obs")) + self.assertEqual( + annotations["names"], + ["name", "n_genes", "percent_mito", "n_counts", "louvain"], + ) self.assertEqual(len(annotations["data"]), 2638) - annotations = self.data.annotation(None, "var") + annotations = json.loads(self.data.annotation(None, "var")) self.assertEqual(annotations["names"], ["name", "n_cells"]) self.assertEqual(len(annotations["data"]), 1838) def test_annotation_fields(self): - annotations = self.data.annotation(None, "obs", ["n_genes", "n_counts"]) + annotations = json.loads( + self.data.annotation(None, "obs", ["n_genes", "n_counts"]) + ) self.assertEqual(annotations["names"], ["n_genes", "n_counts"]) self.assertEqual(len(annotations["data"]), 2638) - annotations = self.data.annotation(None, "var", ["name"]) + annotations = json.loads(self.data.annotation(None, "var", ["name"])) self.assertEqual(annotations["names"], ["name"]) self.assertEqual(len(annotations["data"]), 1838) @@ -127,45 +153,54 @@ class UtilTest(unittest.TestCase): filter_ = { "filter": { "obs": {"annotation_value": [{"name": "n_counts", "min": 3000}]}, - "var": {"annotation_value": [{"name": "name", "values": ["ATAD3C", "RER1"]}]}, + "var": { + "annotation_value": [{"name": "name", "values": ["ATAD3C", "RER1"]}] + }, } } - annotations = self.data.annotation(filter_["filter"], "obs") - self.assertEqual(annotations["names"], ["name", "n_genes", "percent_mito", "n_counts", "louvain"]) + annotations = json.loads(self.data.annotation(filter_["filter"], "obs")) + self.assertEqual( + annotations["names"], + ["name", "n_genes", "percent_mito", "n_counts", "louvain"], + ) self.assertEqual(len(annotations["data"]), 497) - annotations = self.data.annotation(filter_["filter"], "var") + annotations = json.loads(self.data.annotation(filter_["filter"], "var")) self.assertEqual(annotations["names"], ["name", "n_cells"]) self.assertEqual(len(annotations["data"]), 2) def test_filtered_layout(self): - filter_ = {"filter": {"obs": {"annotation_value": [{"name": "n_counts", "min": 3000}]}}} - layout = self.data.layout(filter_["filter"]) - self.assertEqual(len(layout["coordinates"]), 497) + filter_ = { + "filter": {"obs": {"annotation_value": [{"name": "n_counts", "min": 3000}]}} + } + layout = json.loads(self.data.layout(filter_["filter"])) + self.assertEqual(len(layout["layout"]["coordinates"]), 497) def test_diffexp_topN(self): f1 = {"filter": {"obs": {"index": [[0, 500]]}}} f2 = {"filter": {"obs": {"index": [[500, 1000]]}}} - result = self.data.diffexp_topN(f1["filter"], f2["filter"]) + result = json.loads(self.data.diffexp_topN(f1["filter"], f2["filter"])) self.assertEqual(len(result), 10) - result = self.data.diffexp_topN(f1["filter"], f2["filter"], 20) + result = json.loads(self.data.diffexp_topN(f1["filter"], f2["filter"], 20)) self.assertEqual(len(result), 20) def test_data_frame(self): - data_frame_obs = self.data.data_frame(None, "obs") + data_frame_obs = json.loads(self.data.data_frame(None, "obs")) self.assertEqual(len(data_frame_obs["var"]), 1838) self.assertEqual(len(data_frame_obs["obs"]), 2638) - data_frame_var = self.data.data_frame(None, "var") + data_frame_var = json.loads(self.data.data_frame(None, "var")) self.assertEqual(len(data_frame_var["var"]), 1838) self.assertEqual(len(data_frame_var["obs"]), 2638) def test_filtered_data_frame(self): - filter_ = {"filter": {"obs": {"annotation_value": [{"name": "n_counts", "min": 3000}]}}} - data_frame_obs = self.data.data_frame(filter_["filter"], "obs") + filter_ = { + "filter": {"obs": {"annotation_value": [{"name": "n_counts", "min": 3000}]}} + } + data_frame_obs = json.loads(self.data.data_frame(filter_["filter"], "obs")) self.assertEqual(len(data_frame_obs["var"]), 1838) self.assertEqual(len(data_frame_obs["obs"]), 497) self.assertIsInstance(data_frame_obs["obs"][0], (list, tuple)) self.assertEqual(type(data_frame_obs["var"][0]), int) - data_frame_var = self.data.data_frame(filter_["filter"], "var") + data_frame_var = json.loads(self.data.data_frame(filter_["filter"], "var")) self.assertEqual(len(data_frame_var["var"]), 1838) self.assertEqual(len(data_frame_var["obs"]), 497) self.assertIsInstance(data_frame_var["var"][0], (list, tuple)) @@ -173,8 +208,12 @@ class UtilTest(unittest.TestCase): def test_data_single_gene(self): for axis in ["obs", "var"]: - filter_ = {"filter": {"var": {"annotation_value": [{"name": "name", "values": ["RER1"]}]}}} - data_frame_var = self.data.data_frame(filter_["filter"], axis) + filter_ = { + "filter": { + "var": {"annotation_value": [{"name": "name", "values": ["RER1"]}]} + } + } + data_frame_var = json.loads(self.data.data_frame(filter_["filter"], axis)) if axis == "obs": self.assertEqual(type(data_frame_var["var"][0]), int) self.assertIsInstance(data_frame_var["obs"][0], (list, tuple)) diff --git a/setup.cfg b/setup.cfg index c54ecba2..6eb576e1 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,3 +1,3 @@ [flake8] max-line-length = 120 -ignore = E203 \ No newline at end of file +ignore = E203, W503 \ No newline at end of file