hosted gene sets routes, plus a few bug fixes (#2155)

* first cut at hosted gs routes

* lint

* update tests to match csv parser changes

* update tests to new API

* update gene set name validation rules to match requirements

* add path mapping from dataset to geneset

* add test cases for geneset GET route

* fix test assertion

* remove debugging code

* update gene set uri mapping function

* fix error message

* allow extra user-specified headers in gene set csv file

* clarify comment
This commit is contained in:
Bruce Martin
2021-04-27 13:58:58 -07:00
committed by GitHub
parent ebeb1c8818
commit f2e9aecebe
19 changed files with 702 additions and 267 deletions
+21
View File
@@ -318,6 +318,25 @@ class LayoutObsAPI(DatasetResource):
return common_rest.layout_obs_put(request, data_adaptor)
class GenesetsAPI(DatasetResource):
@cache_control(public=True, max_age=ONE_WEEK)
@rest_get_data_adaptor
def get(self, data_adaptor):
return common_rest.genesets_get(request, data_adaptor)
class SummarizeVarAPI(DatasetResource):
@rest_get_data_adaptor
@cache_control(public=True, max_age=ONE_WEEK)
def get(self, data_adaptor):
return common_rest.summarize_var_get(request, data_adaptor)
@rest_get_data_adaptor
@cache_control(no_store=True)
def post(self, data_adaptor):
return common_rest.summarize_var_post(request, data_adaptor)
def get_api_base_resources(bp_base):
"""Add resources that are accessed from the api_base_url"""
api = Api(bp_base)
@@ -343,6 +362,8 @@ def get_api_dataroot_resources(bp_dataroot, url_dataroot=None):
add_resource(AnnotationsObsAPI, "/annotations/obs")
add_resource(AnnotationsVarAPI, "/annotations/var")
add_resource(DataVarAPI, "/data/var")
add_resource(GenesetsAPI, "/genesets")
add_resource(SummarizeVarAPI, "/summarize/var")
# Display routes
add_resource(ColorsAPI, "/colors")
# Computation routes
@@ -1,21 +1,33 @@
from abc import ABCMeta, abstractmethod
import fastobo
import fsspec
import os
from backend.common.errors import OntologyLoadFailure
from flask import current_app, has_request_context
from backend.common.errors import OntologyLoadFailure, DisabledFeatureError
from backend.common.utils.type_conversion_utils import get_schema_type_hint_of_array
from backend.common.genesets import write_gene_sets_tidycsv, read_gene_sets_tidycsv, validate_gene_sets
from backend.common.utils.data_locator import DataLocator
from backend.common.utils.utils import path_join
class Annotations(metaclass=ABCMeta):
""" baseclass for annotations, including ontologies"""
class Annotations:
""" baseclass for annotations, including ontologies and genesets """
""" our default ontology is the PURL for the Cell Ontology.
See http://www.obofoundry.org/ontology/cl.html """
DefaultOnotology = "http://purl.obolibrary.org/obo/cl.obo"
def __init__(self):
def __init__(self, config={}):
self.ontology_data = None
self.config = config
def user_annotations_enabled(self):
return self.config.get("user-annotations", False)
def check_user_annotations_enabled(self):
if not self.user_annotations_enabled():
raise DisabledFeatureError("User annotations are disabled.")
def load_ontology(self, path):
"""Load and parse ontologies - currently support OBO files only."""
@@ -49,22 +61,78 @@ class Annotations(metaclass=ABCMeta):
return schema
@abstractmethod
def set_collection(self, name):
"""set or create a new annotation collection"""
pass
raise NotImplementedError
@abstractmethod
def read_labels(self, data_adaptor):
"""Return the labels as a pandas.DataFrame"""
pass
raise NotImplementedError
@abstractmethod
def write_labels(self, df, data_adaptor):
"""Write the labels (df) to a persistent storage such that it can later be read"""
pass
raise NotImplementedError
@abstractmethod
def update_parameters(self, parameters, data_adaptor):
"""Update configuration parameters that describe information about the annotations feature"""
pass
params = {}
params["annotations_genesets_readonly"] = True
params["annotations_genesets_name_is_read_only"] = True
parameters.update(params)
@staticmethod
def gene_sets_to_csv(genesets):
"""
Convert the internal genesets format (returned by read_gene_set) into
the simple Tidy CSV.
"""
from io import StringIO
if isinstance(genesets, dict):
genesets = genesets.values()
with StringIO() as sio:
write_gene_sets_tidycsv(sio, genesets)
return sio.getvalue()
@staticmethod
def gene_sets_to_response(genesets):
"""
Convert the internal genesets format (returned by read_gene_set) into
the dict expected by the JSON REST API
"""
return list(genesets.values())
def read_gene_sets(self, data_adaptor, context=None):
if has_request_context():
if not current_app.auth.is_user_authenticated():
return ({}, 0)
gene_sets_uri_or_path = dataset_uri_to_geneset_uri(data_adaptor.data_locator.uri_or_path)
server_config = data_adaptor.server_config
region_name = None if server_config is None else server_config.data_locator__s3__region_name
gene_sets_locator = DataLocator(gene_sets_uri_or_path, region_name=region_name)
if not gene_sets_locator.exists():
return ({}, 0)
gene_sets = read_gene_sets_tidycsv(gene_sets_locator, context)
schema = data_adaptor.get_schema()
var_index = schema["annotations"]["var"].get("index", "index")
var_names = set(data_adaptor.query_var_array(var_index))
gene_sets = validate_gene_sets(gene_sets, var_names)
return (gene_sets, 0)
def dataset_uri_to_geneset_uri(data_uri_or_path):
""" given a dataset URI, return the associated gene set URI """
data_basename = os.path.basename(data_uri_or_path)
base, ext = os.path.splitext(data_basename)
if ext is not None: # strip extension, if any
data_basename = base
genesets_basename = f"{data_basename}-genesets.csv"
gene_sets_uri_or_path = path_join(data_uri_or_path, "..", genesets_basename)
return gene_sets_uri_or_path
@@ -17,8 +17,8 @@ from backend.czi_hosted.db.cellxgene_orm import Annotation
class AnnotationsHostedTileDB(Annotations):
CXG_ANNO_COLLECTION = "cxg_anno_collection"
def __init__(self, directory_path, db):
super().__init__()
def __init__(self, config, directory_path, db):
super().__init__(config)
self.db = db
if directory_path[-1] == "/":
self.directory_path = directory_path
@@ -158,6 +158,8 @@ class AnnotationsHostedTileDB(Annotations):
self.db.session.commit()
def update_parameters(self, parameters, data_adaptor):
super().update_parameters(parameters, data_adaptor)
params = {}
params["annotations"] = True
params["user_annotation_collection_name_enabled"] = False
@@ -16,8 +16,8 @@ from backend.common.errors import AnnotationsError
class AnnotationsLocalFile(Annotations):
CXG_ANNO_COLLECTION = "cxg_anno_collection"
def __init__(self, output_dir, output_file):
super().__init__()
def __init__(self, config, output_dir, output_file):
super().__init__(config)
self.output_dir = output_dir
self.output_file = output_file
# lock used to protect label file write ops
@@ -169,6 +169,8 @@ class AnnotationsLocalFile(Annotations):
os.remove(os.path.join(backup_dir, bu))
def update_parameters(self, parameters, data_adaptor):
super().update_parameters(parameters, data_adaptor)
params = {}
params["annotations"] = True
params["user_annotation_collection_name_enabled"] = True
@@ -190,7 +192,7 @@ class AnnotationsLocalFile(Annotations):
collection = self.get_collection()
if current_app.auth.is_user_authenticated():
params["annotations-user-data-idhash"] = self._get_userdata_idhash(data_adaptor)
params["annotations-data-collection-is-read-only"] = False
params["annotations-data-collection-is-read-only"] = not self.user_annotations_enabled()
params["annotations-data-collection-name"] = collection
parameters.update(params)
@@ -44,6 +44,9 @@ def get_client_config(app_config, data_adaptor):
"annotations": False,
"annotations_file": None,
"annotations_dir": None,
"annotations_genesets": True, # feature flag
"annotations_genesets_readonly": True,
"annotations_genesets_summary_methods": ["mean"],
"annotations_cell_ontology_enabled": False,
"annotations_cell_ontology_obopath": None,
"annotations_cell_ontology_terms": None,
@@ -1,6 +1,7 @@
import os
from os.path import splitext, isdir
from backend.czi_hosted.common.annotations.annotations import Annotations
from backend.czi_hosted.common.annotations.hosted_tiledb import AnnotationsHostedTileDB
from backend.czi_hosted.common.annotations.local_file_csv import AnnotationsLocalFile
from backend.czi_hosted.common.config.base_config import BaseConfig
@@ -53,8 +54,10 @@ class DatasetConfig(BaseConfig):
except KeyError as e:
raise ConfigurationError(f"Unexpected config: {str(e)}")
# The annotation object is created during complete_config and stored here.
self.user_annotations = None
# Create the default annotation, which supports gene set reading without
# further configuration. Depending on configuration options, `complete_config`
# may create a more specialized annotation object and replace this default.
self.user_annotations = Annotations()
def complete_config(self, context):
self.handle_app()
@@ -147,7 +150,11 @@ class DatasetConfig(BaseConfig):
except OSError:
raise ConfigurationError("Unable to create directory specified by --annotations-dir")
self.user_annotations = AnnotationsLocalFile(dirname, filename)
anno_config = {
"user-annotations": self.user_annotations__enable,
"genesets-save": False,
}
self.user_annotations = AnnotationsLocalFile(anno_config, dirname, filename)
# if the user has specified a fixed label file, go ahead and validate it
# so that we can remove errors early in the process.
@@ -163,7 +170,12 @@ class DatasetConfig(BaseConfig):
self.validate_correct_type_of_configuration_attribute(
"user_annotations__hosted_tiledb_array__hosted_file_directory", str
)
anno_config = {
"user-annotations": self.user_annotations__enable,
"genesets-save": False,
}
self.user_annotations = AnnotationsHostedTileDB(
anno_config,
directory_path=self.user_annotations__hosted_tiledb_array__hosted_file_directory,
db=DbUtils(self.user_annotations__hosted_tiledb_array__db_uri),
)
+76 -7
View File
@@ -3,6 +3,7 @@ import logging
import sys
from http import HTTPStatus
import zlib
import json
from flask import make_response, jsonify, current_app, abort
from werkzeug.urls import url_unquote
@@ -17,9 +18,10 @@ from backend.common.errors import (
ExceedsLimitError,
DatasetAccessError,
ColorFormatException,
AnnotationsError,
UnsupportedSummaryMethod,
)
import json
from backend.common.genesets import summarizeQueryHash
from backend.common.fbs.matrix import decode_matrix_fbs
@@ -106,7 +108,7 @@ def schema_get_helper(data_adaptor):
# add label obs annotations as needed
annotations = data_adaptor.dataset_config.user_annotations
if annotations is not None:
if annotations.user_annotations_enabled():
label_schema = annotations.get_schema(data_adaptor)
schema["annotations"]["obs"]["columns"].extend(label_schema)
@@ -140,7 +142,7 @@ def annotations_obs_get(request, data_adaptor):
try:
labels = None
annotations = data_adaptor.dataset_config.user_annotations
if 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"})
@@ -151,7 +153,7 @@ def annotations_obs_get(request, data_adaptor):
def annotations_put_fbs_helper(data_adaptor, fbs):
"""helper function to write annotations from fbs"""
annotations = data_adaptor.dataset_config.user_annotations
if annotations is None:
if not annotations.user_annotations_enabled():
raise DisabledFeatureError("Writable annotations are not enabled")
new_label_df = decode_matrix_fbs(fbs)
@@ -166,7 +168,7 @@ def inflate(data):
def annotations_obs_put(request, data_adaptor):
annotations = data_adaptor.dataset_config.user_annotations
if annotations is None:
if not annotations.user_annotations_enabled():
return abort(HTTPStatus.NOT_IMPLEMENTED)
anno_collection = request.args.get("annotation-collection-name", default=None)
@@ -197,7 +199,7 @@ def annotations_var_get(request, data_adaptor):
try:
labels = None
annotations = data_adaptor.dataset_config.user_annotations
if annotations is not None:
if annotations.user_annotations_enabled():
labels = annotations.read_labels(data_adaptor)
return make_response(
data_adaptor.annotation_to_fbs_matrix(Axis.VAR, fields, labels),
@@ -328,3 +330,70 @@ def layout_obs_put(request, data_adaptor):
return abort_and_log(HTTPStatus.NOT_IMPLEMENTED, str(e))
except (ValueError, DisabledFeatureError, FilterError) as e:
return abort_and_log(HTTPStatus.BAD_REQUEST, str(e), 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 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())
+26 -2
View File
@@ -3,11 +3,12 @@ from os.path import basename, splitext
import numpy as np
import pandas as pd
from scipy import sparse
from server_timing import Timing as ServerTiming
from backend.czi_hosted.common.config.app_config import AppConfig
from backend.common.constants import Axis
from backend.common.errors import FilterError, JSONEncodingValueError, ExceedsLimitError
from backend.common.errors import FilterError, JSONEncodingValueError, ExceedsLimitError, UnsupportedSummaryMethod
from backend.common.utils.utils import jsonify_numpy
from backend.common.fbs.matrix import encode_matrix_fbs
@@ -338,7 +339,7 @@ class DataAdaptor(metaclass=ABCMeta):
@staticmethod
def normalize_embedding(embedding):
"""Normalize embedding layout to meet client assumptions.
Embedding is an ndarray, shape (n_obs, n)., where n is normally 2
Embedding is an ndarray, shape (n_obs, n)., where n is normally 2
"""
# scale isotropically
@@ -394,3 +395,26 @@ class DataAdaptor(metaclass=ABCMeta):
except RuntimeError:
lastmod = None
return lastmod
def summarize_var(self, method, filter, query_hash):
if method != "mean":
raise UnsupportedSummaryMethod("Unknown gene set summary method.")
obs_selector, var_selector = self._filter_to_mask(filter)
if obs_selector is not None:
raise FilterError("filtering on obs unsupported")
# if no filter, just return zeros. We don't have a use case
# for summarizing the entire X without a filter, and it would
# potentially be quite compute / memory intensive.
if var_selector is None or np.count_nonzero(var_selector) == 0:
mean = np.zeros((self.get_shape()[0], 1), dtype=np.float32)
else:
X = self.get_X_array(obs_selector, var_selector)
if sparse.issparse(X):
mean = X.mean(axis=1)
else:
mean = X.mean(axis=1, keepdims=True)
col_idx = pd.Index([query_hash])
return encode_matrix_fbs(mean, col_idx=col_idx, row_idx=None)