mirror of
https://github.com/chanzuckerberg/cellxgene.git
synced 2026-09-15 20:57:56 +08:00
400 lines
15 KiB
Python
400 lines
15 KiB
Python
import copy
|
|
import logging
|
|
import sys
|
|
from http import HTTPStatus
|
|
import zlib
|
|
import json
|
|
|
|
from flask import make_response, jsonify, current_app, abort
|
|
from urllib.parse import unquote
|
|
|
|
from server.common.config.client_config import get_client_config
|
|
from server.common.constants import Axis, DiffExpMode, JSON_NaN_to_num_warning_msg
|
|
from server.common.errors import (
|
|
FilterError,
|
|
JSONEncodingValueError,
|
|
PrepareError,
|
|
DisabledFeatureError,
|
|
ExceedsLimitError,
|
|
DatasetAccessError,
|
|
ColorFormatException,
|
|
AnnotationsError,
|
|
ObsoleteRequest,
|
|
UnsupportedSummaryMethod,
|
|
)
|
|
from server.common.genesets import summarizeQueryHash
|
|
from server.common.fbs.matrix import decode_matrix_fbs
|
|
|
|
|
|
def abort_and_log(code, logmsg, loglevel=logging.DEBUG, include_exc_info=False):
|
|
"""
|
|
Log the message, then abort with HTTP code. If include_exc_info is true,
|
|
also include current exception via sys.exc_info().
|
|
"""
|
|
if include_exc_info:
|
|
exc_info = sys.exc_info()
|
|
else:
|
|
exc_info = False
|
|
current_app.logger.log(loglevel, logmsg, exc_info=exc_info)
|
|
# Do NOT send log message to HTTP response.
|
|
return abort(code)
|
|
|
|
|
|
def _query_parameter_to_filter(args):
|
|
"""
|
|
Convert an annotation value filter, if present in the query args,
|
|
into the standard dict filter format used by internal code.
|
|
|
|
Query param filters look like: <axis>:name=value, where value
|
|
may be one of:
|
|
- a range, min,max, where either may be an open range by using an asterisk, eg, 10,*
|
|
- a value
|
|
Eg,
|
|
...?tissue=lung&obs:tissue=heart&obs:num_reads=1000,*
|
|
"""
|
|
filters = {
|
|
"obs": {},
|
|
"var": {},
|
|
}
|
|
|
|
# args has already been url-unquoted once. We assume double escaping
|
|
# on name and value.
|
|
try:
|
|
for key, value in args.items(multi=True):
|
|
axis, name = key.split(":")
|
|
if axis not in ("obs", "var"):
|
|
raise FilterError("unknown filter axis")
|
|
name = unquote(name)
|
|
current = filters[axis].setdefault(name, {"name": name})
|
|
|
|
val_split = value.split(",")
|
|
if len(val_split) == 1:
|
|
if "min" in current or "max" in current:
|
|
raise FilterError("do not mix range and value filters")
|
|
value = unquote(value)
|
|
values = current.setdefault("values", [])
|
|
values.append(value)
|
|
|
|
elif len(val_split) == 2:
|
|
if len(current) > 1:
|
|
raise FilterError("duplicate range specification")
|
|
min = unquote(val_split[0])
|
|
max = unquote(val_split[1])
|
|
if min != "*":
|
|
current["min"] = float(min)
|
|
if max != "*":
|
|
current["max"] = float(max)
|
|
if len(current) < 2:
|
|
raise FilterError("must specify at least min or max in range filter")
|
|
|
|
else:
|
|
raise FilterError("badly formated filter value")
|
|
|
|
except ValueError as e:
|
|
raise FilterError(str(e))
|
|
|
|
result = {}
|
|
for axis in ("obs", "var"):
|
|
axis_filter = filters[axis]
|
|
if len(axis_filter) > 0:
|
|
result[axis] = {"annotation_value": [val for val in axis_filter.values()]}
|
|
|
|
return result
|
|
|
|
|
|
def schema_get_helper(data_adaptor):
|
|
"""helper function to gather the schema from the data source and annotations"""
|
|
schema = data_adaptor.get_schema()
|
|
schema = copy.deepcopy(schema)
|
|
|
|
# add label obs annotations as needed
|
|
annotations = data_adaptor.dataset_config.user_annotations
|
|
if annotations.user_annotations_enabled():
|
|
label_schema = annotations.get_schema(data_adaptor)
|
|
schema["annotations"]["obs"]["columns"].extend(label_schema)
|
|
|
|
return schema
|
|
|
|
|
|
def schema_get(data_adaptor):
|
|
schema = schema_get_helper(data_adaptor)
|
|
return make_response(jsonify({"schema": schema}), HTTPStatus.OK)
|
|
|
|
|
|
def config_get(app_config, data_adaptor):
|
|
config = get_client_config(app_config, data_adaptor)
|
|
return make_response(jsonify(config), HTTPStatus.OK)
|
|
|
|
|
|
def annotations_obs_get(request, data_adaptor):
|
|
fields = request.args.getlist("annotation-name", None)
|
|
num_columns_requested = len(data_adaptor.get_obs_keys()) if len(fields) == 0 else len(fields)
|
|
if data_adaptor.server_config.exceeds_limit("column_request_max", num_columns_requested):
|
|
return abort(HTTPStatus.BAD_REQUEST)
|
|
preferred_mimetype = request.accept_mimetypes.best_match(["application/octet-stream"])
|
|
if preferred_mimetype != "application/octet-stream":
|
|
return abort(HTTPStatus.NOT_ACCEPTABLE)
|
|
|
|
try:
|
|
labels = None
|
|
annotations = data_adaptor.dataset_config.user_annotations
|
|
if annotations.user_annotations_enabled():
|
|
labels = annotations.read_labels(data_adaptor)
|
|
fbs = data_adaptor.annotation_to_fbs_matrix(Axis.OBS, fields, labels)
|
|
return make_response(fbs, HTTPStatus.OK, {"Content-Type": "application/octet-stream"})
|
|
except KeyError as e:
|
|
return abort_and_log(HTTPStatus.BAD_REQUEST, str(e), include_exc_info=True)
|
|
|
|
|
|
def annotations_put_fbs_helper(data_adaptor, fbs):
|
|
"""helper function to write annotations from fbs"""
|
|
annotations = data_adaptor.dataset_config.user_annotations
|
|
if not annotations.user_annotations_enabled():
|
|
raise DisabledFeatureError("Writable annotations are not enabled")
|
|
|
|
new_label_df = decode_matrix_fbs(fbs)
|
|
if not new_label_df.empty:
|
|
new_label_df = data_adaptor.check_new_labels(new_label_df)
|
|
annotations.write_labels(new_label_df, data_adaptor)
|
|
|
|
|
|
def inflate(data):
|
|
return zlib.decompress(data)
|
|
|
|
|
|
def annotations_obs_put(request, data_adaptor):
|
|
annotations = data_adaptor.dataset_config.user_annotations
|
|
if not annotations.user_annotations_enabled():
|
|
return abort(HTTPStatus.NOT_IMPLEMENTED)
|
|
|
|
anno_collection = request.args.get("annotation-collection-name", default=None)
|
|
fbs = inflate(request.get_data())
|
|
|
|
if anno_collection is not None:
|
|
if not annotations.is_safe_collection_name(anno_collection):
|
|
return abort(HTTPStatus.BAD_REQUEST, "Bad annotation collection name")
|
|
annotations.set_collection(anno_collection)
|
|
|
|
try:
|
|
annotations_put_fbs_helper(data_adaptor, fbs)
|
|
res = json.dumps({"status": "OK"})
|
|
return make_response(res, HTTPStatus.OK, {"Content-Type": "application/json"})
|
|
except (ValueError, DisabledFeatureError, KeyError) as e:
|
|
return abort_and_log(HTTPStatus.BAD_REQUEST, str(e), include_exc_info=True)
|
|
|
|
|
|
def annotations_var_get(request, data_adaptor):
|
|
fields = request.args.getlist("annotation-name", None)
|
|
num_columns_requested = len(data_adaptor.get_var_keys()) if len(fields) == 0 else len(fields)
|
|
if data_adaptor.server_config.exceeds_limit("column_request_max", num_columns_requested):
|
|
return abort(HTTPStatus.BAD_REQUEST)
|
|
preferred_mimetype = request.accept_mimetypes.best_match(["application/octet-stream"])
|
|
if preferred_mimetype != "application/octet-stream":
|
|
return abort(HTTPStatus.NOT_ACCEPTABLE)
|
|
|
|
try:
|
|
labels = None
|
|
return make_response(
|
|
data_adaptor.annotation_to_fbs_matrix(Axis.VAR, fields, labels),
|
|
HTTPStatus.OK,
|
|
{"Content-Type": "application/octet-stream"},
|
|
)
|
|
except KeyError as e:
|
|
return abort_and_log(HTTPStatus.BAD_REQUEST, str(e), include_exc_info=True)
|
|
|
|
|
|
def data_var_put(request, data_adaptor):
|
|
preferred_mimetype = request.accept_mimetypes.best_match(["application/octet-stream"])
|
|
if preferred_mimetype != "application/octet-stream":
|
|
return abort(HTTPStatus.NOT_ACCEPTABLE)
|
|
|
|
filter_json = request.get_json()
|
|
filter = filter_json["filter"] if filter_json else None
|
|
try:
|
|
return make_response(
|
|
data_adaptor.data_frame_to_fbs_matrix(filter, axis=Axis.VAR),
|
|
HTTPStatus.OK,
|
|
{"Content-Type": "application/octet-stream"},
|
|
)
|
|
except (FilterError, ValueError, ExceedsLimitError) as e:
|
|
return abort_and_log(HTTPStatus.BAD_REQUEST, str(e), include_exc_info=True)
|
|
|
|
|
|
def data_var_get(request, data_adaptor):
|
|
preferred_mimetype = request.accept_mimetypes.best_match(["application/octet-stream"])
|
|
if preferred_mimetype != "application/octet-stream":
|
|
return abort(HTTPStatus.NOT_ACCEPTABLE)
|
|
|
|
try:
|
|
filter = _query_parameter_to_filter(request.args)
|
|
return make_response(
|
|
data_adaptor.data_frame_to_fbs_matrix(filter, axis=Axis.VAR),
|
|
HTTPStatus.OK,
|
|
{"Content-Type": "application/octet-stream"},
|
|
)
|
|
except (FilterError, ValueError, ExceedsLimitError) as e:
|
|
return abort_and_log(HTTPStatus.BAD_REQUEST, str(e), include_exc_info=True)
|
|
|
|
|
|
def colors_get(data_adaptor):
|
|
if not data_adaptor.dataset_config.presentation__custom_colors:
|
|
return make_response(jsonify({}), HTTPStatus.OK)
|
|
try:
|
|
return make_response(jsonify(data_adaptor.get_colors()), HTTPStatus.OK)
|
|
except ColorFormatException as e:
|
|
return abort_and_log(HTTPStatus.NOT_FOUND, str(e), include_exc_info=True)
|
|
|
|
|
|
def diffexp_obs_post(request, data_adaptor):
|
|
if not data_adaptor.dataset_config.diffexp__enable:
|
|
return abort(HTTPStatus.NOT_IMPLEMENTED)
|
|
|
|
args = request.get_json()
|
|
try:
|
|
# TODO: implement varfilter mode
|
|
mode = DiffExpMode(args["mode"])
|
|
|
|
if mode == DiffExpMode.VAR_FILTER or "varFilter" in args:
|
|
return abort_and_log(HTTPStatus.NOT_IMPLEMENTED, "varFilter not enabled")
|
|
|
|
set1_filter = args.get("set1", {"filter": {}})["filter"]
|
|
set2_filter = args.get("set2", {"filter": {}})["filter"]
|
|
count = args.get("count", None)
|
|
|
|
if set1_filter is None or set2_filter is None or count is None:
|
|
return abort_and_log(HTTPStatus.BAD_REQUEST, "missing required parameter")
|
|
if Axis.VAR in set1_filter or Axis.VAR in set2_filter:
|
|
return abort_and_log(HTTPStatus.BAD_REQUEST, "var axis filter not enabled")
|
|
|
|
except (KeyError, TypeError) as e:
|
|
return abort_and_log(HTTPStatus.BAD_REQUEST, str(e), include_exc_info=True)
|
|
|
|
try:
|
|
diffexp = data_adaptor.diffexp_topN(set1_filter, set2_filter, count)
|
|
return make_response(diffexp, HTTPStatus.OK, {"Content-Type": "application/json"})
|
|
except (ValueError, DisabledFeatureError, FilterError, ExceedsLimitError) as e:
|
|
return abort_and_log(HTTPStatus.BAD_REQUEST, str(e), include_exc_info=True)
|
|
except JSONEncodingValueError:
|
|
# JSON encoding failure, usually due to bad data. Just let it ripple up
|
|
# to default exception handler.
|
|
current_app.logger.warning(JSON_NaN_to_num_warning_msg)
|
|
raise
|
|
|
|
|
|
def layout_obs_get(request, data_adaptor):
|
|
fields = request.args.getlist("layout-name", None)
|
|
num_columns_requested = len(data_adaptor.get_embedding_names()) if len(fields) == 0 else len(fields)
|
|
if data_adaptor.server_config.exceeds_limit("column_request_max", num_columns_requested):
|
|
return abort(HTTPStatus.BAD_REQUEST)
|
|
|
|
preferred_mimetype = request.accept_mimetypes.best_match(["application/octet-stream"])
|
|
if preferred_mimetype != "application/octet-stream":
|
|
return abort(HTTPStatus.NOT_ACCEPTABLE)
|
|
|
|
try:
|
|
return make_response(
|
|
data_adaptor.layout_to_fbs_matrix(fields), HTTPStatus.OK, {"Content-Type": "application/octet-stream"}
|
|
)
|
|
except (KeyError, DatasetAccessError) as e:
|
|
return abort_and_log(HTTPStatus.BAD_REQUEST, str(e), include_exc_info=True)
|
|
except PrepareError:
|
|
return abort_and_log(
|
|
HTTPStatus.NOT_IMPLEMENTED,
|
|
f"No embedding available {request.path}",
|
|
loglevel=logging.ERROR,
|
|
include_exc_info=True,
|
|
)
|
|
|
|
|
|
def genesets_get(request, data_adaptor):
|
|
preferred_mimetype = request.accept_mimetypes.best_match(["application/json", "text/csv"])
|
|
if preferred_mimetype not in ("application/json", "text/csv"):
|
|
return abort(HTTPStatus.NOT_ACCEPTABLE)
|
|
|
|
try:
|
|
annotations = data_adaptor.dataset_config.user_annotations
|
|
(genesets, tid) = annotations.read_gene_sets(data_adaptor)
|
|
|
|
if preferred_mimetype == "text/csv":
|
|
return make_response(
|
|
annotations.gene_sets_to_csv(genesets),
|
|
HTTPStatus.OK,
|
|
{
|
|
"Content-Type": "text/csv",
|
|
"Content-Disposition": "attachment; filename=genesets.csv",
|
|
},
|
|
)
|
|
else:
|
|
return make_response(
|
|
jsonify({"genesets": annotations.gene_sets_to_response(genesets), "tid": tid}), HTTPStatus.OK
|
|
)
|
|
except (ValueError, KeyError, AnnotationsError) as e:
|
|
return abort_and_log(HTTPStatus.BAD_REQUEST, str(e))
|
|
|
|
|
|
def genesets_put(request, data_adaptor):
|
|
annotations = data_adaptor.dataset_config.user_annotations
|
|
if not annotations.gene_sets_save_enabled():
|
|
return abort(HTTPStatus.NOT_IMPLEMENTED)
|
|
|
|
anno_collection = request.args.get("annotation-collection-name", default=None)
|
|
if anno_collection is not None:
|
|
if not annotations.is_safe_collection_name(anno_collection):
|
|
return abort(HTTPStatus.BAD_REQUEST, "Bad annotation collection name")
|
|
annotations.set_collection(anno_collection)
|
|
|
|
args = request.get_json()
|
|
try:
|
|
genesets = args.get("genesets", None)
|
|
tid = args.get("tid", None)
|
|
if genesets is None:
|
|
abort(HTTPStatus.BAD_REQUEST)
|
|
|
|
annotations.write_gene_sets(genesets, tid, data_adaptor)
|
|
return make_response(jsonify({"status": "OK"}), HTTPStatus.OK)
|
|
except (ValueError, DisabledFeatureError, KeyError) as e:
|
|
return abort_and_log(HTTPStatus.BAD_REQUEST, str(e), include_exc_info=True)
|
|
except (ObsoleteRequest, TypeError) as e:
|
|
return abort(HTTPStatus.NOT_FOUND, description=str(e))
|
|
|
|
|
|
def summarize_var_helper(request, data_adaptor, key, raw_query):
|
|
preferred_mimetype = request.accept_mimetypes.best_match(["application/octet-stream"])
|
|
if preferred_mimetype != "application/octet-stream":
|
|
return abort(HTTPStatus.NOT_ACCEPTABLE)
|
|
|
|
summary_method = request.values.get("method", default="mean")
|
|
query_hash = summarizeQueryHash(raw_query)
|
|
if key and query_hash != key:
|
|
return abort(HTTPStatus.BAD_REQUEST, description="query key did not match")
|
|
|
|
args_filter_only = request.values.copy()
|
|
args_filter_only.poplist("method")
|
|
args_filter_only.poplist("key")
|
|
|
|
try:
|
|
filter = _query_parameter_to_filter(args_filter_only)
|
|
return make_response(
|
|
data_adaptor.summarize_var(summary_method, filter, query_hash),
|
|
HTTPStatus.OK,
|
|
{"Content-Type": "application/octet-stream"},
|
|
)
|
|
except ValueError as e:
|
|
return abort(HTTPStatus.NOT_FOUND, description=str(e))
|
|
except (UnsupportedSummaryMethod, FilterError) as e:
|
|
return abort(HTTPStatus.BAD_REQUEST, description=str(e))
|
|
|
|
|
|
def summarize_var_get(request, data_adaptor):
|
|
return summarize_var_helper(request, data_adaptor, None, request.query_string)
|
|
|
|
|
|
def summarize_var_post(request, data_adaptor):
|
|
if not request.content_type or "application/x-www-form-urlencoded" not in request.content_type:
|
|
return abort(HTTPStatus.UNSUPPORTED_MEDIA_TYPE)
|
|
if request.content_length > 1_000_000: # just a sanity check to avoid memory exhaustion
|
|
return abort(HTTPStatus.BAD_REQUEST)
|
|
|
|
key = request.args.get("key", default=None)
|
|
return summarize_var_helper(request, data_adaptor, key, request.get_data())
|