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 00000000..d97078b0 Binary files /dev/null and b/server/test/test_datasets/nan.h5ad differ 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