Add user-generated annotations tests to the server (#1164)

* Add user-generated annotations tests to the server

Partially completes https://github.com/chanzuckerberg/cellxgene/issues/969

* Auto-format python code

* @skip_if: passing lambdas > than property strings

* Respond to feedback from @bkmartinjr
This commit is contained in:
Matt Weiden
2020-02-23 15:32:13 -08:00
committed by GitHub
parent fb1f0c6469
commit c7f2032dd7
65 changed files with 377 additions and 223 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
include ../common.mk include ../common.mk
DATASET := $(if $(DATASET),$(DATASET),../example-dataset/pbmc3k.h5ad) DATASET := $(if $(DATASET),$(DATASET),../example-dataset/pbmc3k.h5ad)
ANNOTATIONS := $(if $(ANNOTATIONS),$(ANNOTATIONS),../example-dataset/pbmc3k-annotations.csv) ANNOTATIONS := $(if $(ANNOTATIONS),$(ANNOTATIONS),../server/test/test_datasets/pbmc3k-annotations.csv)
ANNOTATIONS_FILENAME := $(shell basename $(ANNOTATIONS)) ANNOTATIONS_FILENAME := $(shell basename $(ANNOTATIONS))
# Packaging # Packaging
+1 -1
View File
@@ -17,7 +17,7 @@ exclude = '''
| buck-out | buck-out
| build | build
| dist | dist
| server/app/util/fbs/NetEncoding | server/data_common/fbs/NetEncoding
)/ )/
) )
+6 -9
View File
@@ -82,12 +82,12 @@ def rest_get_data_adaptor(func):
def static_redirect(dataset, therest): def static_redirect(dataset, therest):
""" redirect all static requests to the standard location """ """ redirect all static requests to the standard location """
return redirect(f'/static/{therest}', code=301) return redirect(f"/static/{therest}", code=301)
def favicon_redirect(dataset): def favicon_redirect(dataset):
""" redirect favicon to static dir """ """ redirect favicon to static dir """
return redirect('/static/favicon.png', code=301) return redirect("/static/favicon.png", code=301)
def dataroot_index(): def dataroot_index():
@@ -127,20 +127,17 @@ class SchemaAPI(Resource):
class ConfigAPI(Resource): class ConfigAPI(Resource):
@rest_get_data_adaptor @rest_get_data_adaptor
def get(self, data_adaptor): def get(self, data_adaptor):
return common_rest.config_get( return common_rest.config_get(current_app.app_config, data_adaptor, current_app.annotations)
current_app.app_config, data_adaptor, current_app.annotations)
class AnnotationsObsAPI(Resource): class AnnotationsObsAPI(Resource):
@rest_get_data_adaptor @rest_get_data_adaptor
def get(self, data_adaptor): def get(self, data_adaptor):
return common_rest.annotations_obs_get( return common_rest.annotations_obs_get(request, data_adaptor, current_app.annotations)
request, data_adaptor, current_app.annotations)
@rest_get_data_adaptor @rest_get_data_adaptor
def put(self, data_adaptor): def put(self, data_adaptor):
return common_rest.annotations_obs_put( return common_rest.annotations_obs_put(request, data_adaptor, current_app.annotations)
request, data_adaptor, current_app.annotations)
class AnnotationsVarAPI(Resource): class AnnotationsVarAPI(Resource):
@@ -216,7 +213,7 @@ class Server:
bp_api = Blueprint("api_dataset", __name__, url_prefix="/<dataset>" + api_version) bp_api = Blueprint("api_dataset", __name__, url_prefix="/<dataset>" + api_version)
resources = get_api_resources(bp_api) resources = get_api_resources(bp_api)
self.app.register_blueprint(resources.blueprint) self.app.register_blueprint(resources.blueprint)
self.app.add_url_rule("/<dataset>/", 'dataset_index', dataset_index) self.app.add_url_rule("/<dataset>/", "dataset_index", dataset_index)
self.app.add_url_rule("/<dataset>/static/<path:therest>", "static_redirect", static_redirect) self.app.add_url_rule("/<dataset>/static/<path:therest>", "static_redirect", static_redirect)
self.app.add_url_rule("/<dataset>/favicon.png", "favicon_redirect", favicon_redirect) self.app.add_url_rule("/<dataset>/favicon.png", "favicon_redirect", favicon_redirect)
+15 -19
View File
@@ -20,7 +20,7 @@ from server.common.errors import OntologyLoadFailure
# anything bigger than this will generate a special message # anything bigger than this will generate a special message
BIG_FILE_SIZE_THRESHOLD = 100 * 2 ** 20 # 100MB BIG_FILE_SIZE_THRESHOLD = 100 * 2 ** 20 # 100MB
DEFAULT_SERVER_PORT = int(environ.get('CXG_SERVER_PORT', '5005')) DEFAULT_SERVER_PORT = int(environ.get("CXG_SERVER_PORT", "5005"))
def annotation_args(func): def annotation_args(func):
@@ -54,14 +54,14 @@ def annotation_args(func):
is_flag=True, is_flag=True,
default=False, default=False,
show_default=True, show_default=True,
help="When creating annotations, optionally autocomplete names from ontology terms." help="When creating annotations, optionally autocomplete names from ontology terms.",
) )
@click.option( @click.option(
"--experimental-annotations-ontology-obo", "--experimental-annotations-ontology-obo",
default=None, default=None,
show_default=True, show_default=True,
metavar="<path or url>", metavar="<path or url>",
help="Location of OBO file defining cell annotation autosuggest terms." help="Location of OBO file defining cell annotation autosuggest terms.",
) )
@functools.wraps(func) @functools.wraps(func)
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
@@ -110,7 +110,6 @@ def config_args(func):
def dataset_args(func): def dataset_args(func):
@click.option( @click.option(
"--obs-names", "--obs-names",
"-obs", "-obs",
@@ -131,15 +130,9 @@ def dataset_args(func):
is_flag=True, is_flag=True,
default=False, default=False,
show_default=False, show_default=False,
help="Load anndata in file-backed mode. " help="Load anndata in file-backed mode. " "This may save memory, but may result in slower overall performance.",
"This may save memory, but may result in slower overall performance.",
)
@click.option(
"--title",
"-t",
metavar="<text>",
help="Title to display. If omitted will use file name."
) )
@click.option("--title", "-t", metavar="<text>", help="Title to display. If omitted will use file name.")
@click.option( @click.option(
"--about", "--about",
metavar="<URL>", metavar="<URL>",
@@ -212,8 +205,9 @@ def launch_args(func):
default=None, default=None,
metavar="<data directory>", metavar="<data directory>",
help="Enable cellxgene to serve multiple files. Supply path (local directory or URL)" help="Enable cellxgene to serve multiple files. Supply path (local directory or URL)"
" to folder containing H5AD and/or CXG datasets.", " to folder containing H5AD and/or CXG datasets.",
hidden=True) # TODO, unhide when dataroot is supported) hidden=True,
) # TODO, unhide when dataroot is supported)
@click.argument("datapath", required=False, metavar="<path to data file>") @click.argument("datapath", required=False, metavar="<path to data file>")
@click.option( @click.option(
"--open", "--open",
@@ -282,7 +276,7 @@ def launch(
backed, backed,
disable_diffexp, disable_diffexp,
experimental_annotations_ontology, experimental_annotations_ontology,
experimental_annotations_ontology_obo experimental_annotations_ontology_obo,
): ):
"""Launch the cellxgene data viewer. """Launch the cellxgene data viewer.
This web app lets you explore single-cell expression data. This web app lets you explore single-cell expression data.
@@ -308,7 +302,7 @@ def launch(
if datapath is None and dataroot is None: if datapath is None and dataroot is None:
# TODO: change the error message once dataroot is fully supported # TODO: change the error message once dataroot is fully supported
raise click.ClickException("Missing argument \"<path to data file>.\"") raise click.ClickException('Missing argument "<path to data file>."')
# raise click.ClickException("must supply either <path to data file> or --dataroot") # raise click.ClickException("must supply either <path to data file> or --dataroot")
if datapath is not None and dataroot is not None: if datapath is not None and dataroot is not None:
raise click.ClickException("must supply only one of <path to data file> or --dataroot") raise click.ClickException("must supply only one of <path to data file> or --dataroot")
@@ -376,6 +370,7 @@ def launch(
) )
if about: if about:
def url_check(url): def url_check(url):
try: try:
result = urlparse(url) result = urlparse(url)
@@ -405,7 +400,8 @@ def launch(
obs_names=obs_names, obs_names=obs_names,
var_names=var_names, var_names=var_names,
anndata_backed=backed, anndata_backed=backed,
disable_diffexp=disable_diffexp) disable_diffexp=disable_diffexp,
)
matrix_data_cache_manager = MatrixDataCacheManager() matrix_data_cache_manager = MatrixDataCacheManager()
data_adaptor = None data_adaptor = None
@@ -424,8 +420,7 @@ def launch(
annotations = None annotations = None
if experimental_annotations: if experimental_annotations:
annotations = AnnotationsLocalFile(experimental_annotations_output_dir, annotations = AnnotationsLocalFile(experimental_annotations_output_dir, experimental_annotations_file)
experimental_annotations_file)
# if the user has specified a fixed label file, go ahead and validate it # if the user has specified a fixed label file, go ahead and validate it
# so that we can remove errors early in the process. # so that we can remove errors early in the process.
@@ -441,6 +436,7 @@ def launch(
# create the server # create the server
from server.app.app import Server from server.app.app import Server
server = Server(matrix_data_cache_manager, annotations, app_config) server = Server(matrix_data_cache_manager, annotations, app_config)
if not verbose: if not verbose:
+5 -4
View File
@@ -20,7 +20,7 @@ def log_upgrade_check():
# Get the current latest release # Get the current latest release
try: try:
release_tag_generator = (r['tag_name'] for r in _request_cellxgene_releases()) release_tag_generator = (r["tag_name"] for r in _request_cellxgene_releases())
latest_release = next(release_tag_generator, lambda tag_name: validate_version_str(tag_name)) latest_release = next(release_tag_generator, lambda tag_name: validate_version_str(tag_name))
if version_gt(latest_release, __version__): if version_gt(latest_release, __version__):
click.echo(f"There's a new version of cellxgene available ({latest_release})!") click.echo(f"There's a new version of cellxgene available ({latest_release})!")
@@ -37,15 +37,16 @@ class RateLimitException(Exception):
def _request_cellxgene_releases(): def _request_cellxgene_releases():
def raise_on_rate_limit(response): def raise_on_rate_limit(response):
if response.status_code == 403 and res.headers.get('X-RateLimit-Remaining') == '0': if response.status_code == 403 and res.headers.get("X-RateLimit-Remaining") == "0":
raise RateLimitException raise RateLimitException
url = "https://api.github.com/repos/chanzuckerberg/cellxgene/releases" url = "https://api.github.com/repos/chanzuckerberg/cellxgene/releases"
res = requests.get(url) res = requests.get(url)
raise_on_rate_limit(res) raise_on_rate_limit(res)
for release in res.json(): for release in res.json():
yield release yield release
while 'next' in res.links.keys(): while "next" in res.links.keys():
res = requests.get(res.links['next']['url']) res = requests.get(res.links["next"]["url"])
raise_on_rate_limit(res) raise_on_rate_limit(res)
for release in res.json(): for release in res.json():
yield release yield release
+1 -1
View File
@@ -42,7 +42,7 @@ class Annotations(metaclass=ABCMeta):
raise OntologyLoadFailure(f"Unable to find OBO ontology path: {path}") from e raise OntologyLoadFailure(f"Unable to find OBO ontology path: {path}") from e
except SyntaxError as e: except SyntaxError as e:
msg = ''.join(traceback.format_exception_only(SyntaxError, e)) msg = "".join(traceback.format_exception_only(SyntaxError, e))
raise OntologyLoadFailure(msg) from e raise OntologyLoadFailure(msg) from e
except Exception as e: except Exception as e:
+17 -13
View File
@@ -13,16 +13,12 @@ class AppFeature(object):
setattr(self, k, v) setattr(self, k, v)
def todict(self): def todict(self):
d = dict( d = dict(available=self.available, method=self.method, path=self.path)
available=self.available,
method=self.method,
path=self.path)
d.update(self.extra) d.update(self.extra)
return d return d
class AppConfig(object): class AppConfig(object):
def __init__(self, **kw): def __init__(self, **kw):
super().__init__() super().__init__()
@@ -47,10 +43,20 @@ class AppConfig(object):
# parameters # parameters
self.diffexp_may_be_slow = False self.diffexp_may_be_slow = False
inputs = ["datapath", "dataroot", "title", "about", "scripts", "layout", inputs = [
"max_category_items", "diffexp_lfc_cutoff", "datapath",
"obs_names", "var_names", "dataroot",
"anndata_backed", "disable_diffexp"] "title",
"about",
"scripts",
"layout",
"max_category_items",
"diffexp_lfc_cutoff",
"obs_names",
"var_names",
"anndata_backed",
"disable_diffexp",
]
self.update(inputs, kw) self.update(inputs, kw)
@@ -80,9 +86,7 @@ class AppConfig(object):
title = self.get_title(data_adaptor) title = self.get_title(data_adaptor)
about = self.get_about(data_adaptor) about = self.get_about(data_adaptor)
display_names = dict( display_names = dict(engine=data_adaptor.get_name(), dataset=title)
engine=data_adaptor.get_name(),
dataset=title)
# library_versions # library_versions
library_versions = {} library_versions = {}
@@ -90,7 +94,7 @@ class AppConfig(object):
library_versions["cellxgene"] = cellxgene_version library_versions["cellxgene"] = cellxgene_version
# links # links
links = {"about-dataset" : about} links = {"about-dataset": about}
# parameters # parameters
parameters = { parameters = {
+1 -1
View File
@@ -84,7 +84,7 @@ class DataLocator:
# and clean it up when done. If the path has a suffix/extension, # and clean it up when done. If the path has a suffix/extension,
# do our best to create a file with the same. # do our best to create a file with the same.
ext = os.path.splitext(self.path) ext = os.path.splitext(self.path)
suffix = None if ext[1] == '' else ext[1] suffix = None if ext[1] == "" else ext[1]
with self.open() as src, tempfile.NamedTemporaryFile(prefix="cellxgene_", suffix=suffix, delete=False) as tmp: with self.open() as src, tempfile.NamedTemporaryFile(prefix="cellxgene_", suffix=suffix, delete=False) as tmp:
tmp.write(src.read()) tmp.write(src.read())
tmp.close() tmp.close()
+8
View File
@@ -2,6 +2,7 @@ class FilterError(Exception):
""" """
Raised when filter is malformed Raised when filter is malformed
""" """
pass pass
@@ -9,6 +10,7 @@ class JSONEncodingValueError(Exception):
""" """
Raised when data cannot be encoded into json Raised when data cannot be encoded into json
""" """
pass pass
@@ -16,6 +18,7 @@ class MimeTypeError(Exception):
""" """
Raised when incompatible MIME type selected Raised when incompatible MIME type selected
""" """
pass pass
@@ -23,6 +26,7 @@ class PrepareError(Exception):
""" """
Raised when data is misprepared Raised when data is misprepared
""" """
pass pass
@@ -30,6 +34,7 @@ class DatasetAccessError(Exception):
""" """
Raised when file loaded into a DataAdaptor is misformatted Raised when file loaded into a DataAdaptor is misformatted
""" """
pass pass
@@ -37,6 +42,7 @@ class DisabledFeatureError(Exception):
""" """
Raised when an attempt to use a disabled feature occurs Raised when an attempt to use a disabled feature occurs
""" """
pass pass
@@ -44,6 +50,7 @@ class AnnotationsError(Exception):
""" """
Raised when an attempt to use the annotations feature fails Raised when an attempt to use the annotations feature fails
""" """
pass pass
@@ -51,4 +58,5 @@ class OntologyLoadFailure(Exception):
""" """
Raised when reading the ontology file fails Raised when reading the ontology file fails
""" """
pass pass
+1 -3
View File
@@ -29,9 +29,7 @@ def schema_get_helper(data_adaptor, annotations):
def schema_get(data_adaptor, annotations): def schema_get(data_adaptor, annotations):
schema = schema_get_helper(data_adaptor, annotations) schema = schema_get_helper(data_adaptor, annotations)
return make_response( return make_response(jsonify({"schema": schema}), HTTPStatus.OK)
jsonify({"schema": schema}), HTTPStatus.OK
)
def config_get(app_config, data_adaptor, annotations): def config_get(app_config, data_adaptor, annotations):
+5 -7
View File
@@ -92,20 +92,18 @@ def jsonify_numpy(data):
def dtype_to_schema(dtype): def dtype_to_schema(dtype):
schema = {} schema = {}
if dtype == np.float32: if dtype == np.float32:
schema['type'] = 'float32' schema["type"] = "float32"
elif dtype == np.int32: elif dtype == np.int32:
schema['type'] = 'int32' schema["type"] = "int32"
elif dtype == np.bool_: elif dtype == np.bool_:
schema['type'] = 'boolean' schema["type"] = "boolean"
elif dtype == np.str: elif dtype == np.str:
schema['type'] = 'string' schema["type"] = "string"
elif dtype == "category": elif dtype == "category":
schema["type"] = "categorical" schema["type"] = "categorical"
schema["categories"] = dtype.categories.tolist() schema["categories"] = dtype.categories.tolist()
else: else:
raise TypeError( raise TypeError(f"Annotations of type {dtype} are unsupported.")
f"Annotations of type {dtype} are unsupported."
)
return schema return schema
+1 -1
View File
@@ -287,7 +287,7 @@ def create_emb(e_name, emb):
* large tile size (1000) * large tile size (1000)
* default compression level * default compression level
""" """
filters = tiledb.FilterList([tiledb.ZstdFilter(), ]) filters = tiledb.FilterList([tiledb.ZstdFilter()])
attrs = [tiledb.Attr(dtype=emb.dtype, filters=filters)] attrs = [tiledb.Attr(dtype=emb.dtype, filters=filters)]
dims = [] dims = []
for d in range(emb.ndim): for d in range(emb.ndim):
+4 -4
View File
@@ -23,7 +23,6 @@ def anndata_version_is_pre_070():
class AnndataAdaptor(DataAdaptor): class AnndataAdaptor(DataAdaptor):
def __init__(self, data_locator, config=None): def __init__(self, data_locator, config=None):
super().__init__(config) super().__init__(config)
self.data = None self.data = None
@@ -127,7 +126,6 @@ class AnndataAdaptor(DataAdaptor):
"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": { "annotations": {
"obs": {"index": self.parameters.get("obs_names"), "columns": []}, "obs": {"index": self.parameters.get("obs_names"), "columns": []},
"var": {"index": self.parameters.get("var_names"), "columns": []}, "var": {"index": self.parameters.get("var_names"), "columns": []},
}, },
"layout": {"obs": []}, "layout": {"obs": []},
@@ -177,8 +175,10 @@ class AnndataAdaptor(DataAdaptor):
def _validate_and_initialize(self): def _validate_and_initialize(self):
if anndata_version_is_pre_070() and self.config.anndata_backed: if anndata_version_is_pre_070() and self.config.anndata_backed:
warnings.warn(f"Use of --backed mode with anndata versions older than 0.7 will have serious " warnings.warn(
"performance issues. Please update to at least anndata 0.7 or later.") f"Use of --backed mode with anndata versions older than 0.7 will have serious "
"performance issues. Please update to at least anndata 0.7 or later."
)
# var and obs column names must be unique # var and obs column names must be unique
if not self.data.obs.columns.is_unique or not self.data.var.columns.is_unique: if not self.data.obs.columns.is_unique or not self.data.var.columns.is_unique:
+13 -15
View File
@@ -132,16 +132,14 @@ class DataAdaptor(metaclass=ABCMeta):
if self.get_embedding_names(): if self.get_embedding_names():
# TODO handle "var" when gene layout becomes available # TODO handle "var" when gene layout becomes available
features["layout_obs"] = AppFeature( features["layout_obs"] = AppFeature("/layout/obs", available=True)
"/layout/obs", available=True)
else: else:
features["layout_obs"] = AppFeature("/layout/obs") features["layout_obs"] = AppFeature("/layout/obs")
if self.config.disable_diffexp: if self.config.disable_diffexp:
features["diffexp"] = AppFeature("/diffexp/") features["diffexp"] = AppFeature("/diffexp/")
else: else:
features["diffexp"] = AppFeature( features["diffexp"] = AppFeature("/diffexp/", available=True)
"/diffexp/", available=True)
return features return features
@@ -152,17 +150,17 @@ class DataAdaptor(metaclass=ABCMeta):
mask = np.zeros((count,), dtype=np.bool) mask = np.zeros((count,), dtype=np.bool)
for i in filter: for i in filter:
if type(i) == list: if type(i) == list:
mask[i[0]: i[1]] = True mask[i[0] : i[1]] = True
else: else:
mask[i] = True mask[i] = True
return mask return mask
def _axis_filter_to_mask(self, axis, filter, count): def _axis_filter_to_mask(self, axis, filter, count):
mask = np.ones((count, ), dtype=np.bool) mask = np.ones((count,), dtype=np.bool)
if 'index' in filter: if "index" in filter:
mask = np.logical_and(mask, self._index_filter_to_mask(filter['index'], count)) mask = np.logical_and(mask, self._index_filter_to_mask(filter["index"], count))
if 'annotation_value' in filter: if "annotation_value" in filter:
mask = np.logical_and(mask, self._annotation_filter_to_mask(axis, filter['annotation_value'], count)) mask = np.logical_and(mask, self._annotation_filter_to_mask(axis, filter["annotation_value"], count))
return mask return mask
@@ -176,7 +174,7 @@ class DataAdaptor(metaclass=ABCMeta):
anno_data = self.query_obs_array(name) anno_data = self.query_obs_array(name)
if anno_data.dtype.name in ["boolean", "category", "object"]: if anno_data.dtype.name in ["boolean", "category", "object"]:
values = v.get('values', []) values = v.get("values", [])
key_idx = np.in1d(anno_data, values) key_idx = np.in1d(anno_data, values)
mask = np.logical_and(mask, key_idx) mask = np.logical_and(mask, key_idx)
@@ -202,10 +200,10 @@ class DataAdaptor(metaclass=ABCMeta):
obs_selector = None obs_selector = None
if filter is not None: if filter is not None:
if Axis.OBS in filter: if Axis.OBS in filter:
obs_selector = self._axis_filter_to_mask(Axis.OBS, filter['obs'], shape[0]) obs_selector = self._axis_filter_to_mask(Axis.OBS, filter["obs"], shape[0])
if Axis.VAR in filter: if Axis.VAR in filter:
var_selector = self._axis_filter_to_mask(Axis.VAR, filter['var'], shape[1]) var_selector = self._axis_filter_to_mask(Axis.VAR, filter["var"], shape[1])
return (obs_selector, var_selector) return (obs_selector, var_selector)
@@ -309,7 +307,7 @@ class DataAdaptor(metaclass=ABCMeta):
embeddings = self.get_embedding_names() embeddings = self.get_embedding_names()
layout_data = [] layout_data = []
with ServerTiming.time(f'layout.query'): with ServerTiming.time(f"layout.query"):
for ename in embeddings: for ename in embeddings:
embedding = self.get_embedding_array(ename, 2) embedding = self.get_embedding_array(ename, 2)
@@ -326,7 +324,7 @@ class DataAdaptor(metaclass=ABCMeta):
normalized_layout = normalized_layout.astype(dtype=np.float32) normalized_layout = normalized_layout.astype(dtype=np.float32)
layout_data.append(pd.DataFrame(normalized_layout, columns=[f"{ename}_0", f"{ename}_1"])) layout_data.append(pd.DataFrame(normalized_layout, columns=[f"{ename}_0", f"{ename}_1"]))
with ServerTiming.time(f'layout.encode'): with ServerTiming.time(f"layout.encode"):
if layout_data: if layout_data:
df = pd.concat(layout_data, axis=1, copy=False) df = pd.concat(layout_data, axis=1, copy=False)
else: else:
+2 -1
View File
@@ -135,7 +135,6 @@ class MatrixDataType(Enum):
class MatrixDataLoader(object): class MatrixDataLoader(object):
def __init__(self, location, etype=None): def __init__(self, location, etype=None):
self.location = location self.location = location
if etype is None: if etype is None:
@@ -145,9 +144,11 @@ class MatrixDataLoader(object):
self.matrix_type = None self.matrix_type = None
if self.etype == MatrixDataType.H5AD: if self.etype == MatrixDataType.H5AD:
from server.data_anndata.anndata_adaptor import AnndataAdaptor from server.data_anndata.anndata_adaptor import AnndataAdaptor
self.matrix_type = AnndataAdaptor self.matrix_type = AnndataAdaptor
elif self.etype == MatrixDataType.CXG: elif self.etype == MatrixDataType.CXG:
from server.data_cxg.cxg_adaptor import CxgAdaptor from server.data_cxg.cxg_adaptor import CxgAdaptor
self.matrix_type = CxgAdaptor self.matrix_type = CxgAdaptor
def matrix_data_type(self): def matrix_data_type(self):
+1
View File
@@ -25,6 +25,7 @@ from threading import Lock
# _______________________________________________________________________ # _______________________________________________________________________
# Class # Class
class RWLock(object): class RWLock(object):
""" RWLock class; this is meant to allow an object to be read from by """ RWLock class; this is meant to allow an object to be read from by
multiple threads, but only written to by a single thread at a time. See: multiple threads, but only written to by a single thread at a time. See:
+40 -60
View File
@@ -16,10 +16,7 @@ import threading
class CxgAdaptor(DataAdaptor): class CxgAdaptor(DataAdaptor):
# TODO: The tiledb context parameters should be a configuration option # TODO: The tiledb context parameters should be a configuration option
tiledb_ctx = tiledb.Ctx({ tiledb_ctx = tiledb.Ctx({"sm.tile_cache_size": 8 * 1024 * 1024 * 1024, "sm.num_reader_threads": 32})
'sm.tile_cache_size': 8 * 1024 * 1024 * 1024,
'sm.num_reader_threads': 32,
})
def __init__(self, location, config=None): def __init__(self, location, config=None):
super().__init__(config) super().__init__(config)
@@ -28,8 +25,8 @@ class CxgAdaptor(DataAdaptor):
self.lock = threading.Lock() self.lock = threading.Lock()
self.url = location self.url = location
if self.url[-1] != '/': if self.url[-1] != "/":
self.url += '/' self.url += "/"
self._validate_and_initialize() self._validate_and_initialize()
@@ -79,19 +76,18 @@ class CxgAdaptor(DataAdaptor):
returns list of (absolute paths, type) *without* trailing slash returns list of (absolute paths, type) *without* trailing slash
in the path. in the path.
""" """
def _cleanpath(p): def _cleanpath(p):
if p[-1] == '/': if p[-1] == "/":
return p[:-1] return p[:-1]
else: else:
return p return p
if uri[-1] != '/': if uri[-1] != "/":
uri += '/' uri += "/"
result = [] result = []
tiledb.ls(uri, tiledb.ls(uri, lambda path, type: result.append((_cleanpath(path), type)), ctx=self.tiledb_ctx)
lambda path, type: result.append((_cleanpath(path), type)),
ctx=self.tiledb_ctx)
return result return result
@staticmethod @staticmethod
@@ -135,13 +131,13 @@ class CxgAdaptor(DataAdaptor):
elif a_type == "array": elif a_type == "array":
# version >0 # version >0
gmd = self.open_array("cxg_group_metadata") gmd = self.open_array("cxg_group_metadata")
cxg_version = gmd.meta['cxg_version'] cxg_version = gmd.meta["cxg_version"]
if cxg_version == "0.1": if cxg_version == "0.1":
cxg_properties = json.loads(gmd.meta['cxg_properties']) cxg_properties = json.loads(gmd.meta["cxg_properties"])
title = cxg_properties.get('title', None) title = cxg_properties.get("title", None)
about = cxg_properties.get('about', None) about = cxg_properties.get("about", None)
if cxg_version not in ['0.0', '0.1']: if cxg_version not in ["0.0", "0.1"]:
raise DatasetAccessError(f"cxg matrix is not valid: {self.url}") raise DatasetAccessError(f"cxg matrix is not valid: {self.url}")
self.title = title self.title = title
@@ -175,7 +171,7 @@ class CxgAdaptor(DataAdaptor):
if obs_items == slice(None) and var_items == slice(None): if obs_items == slice(None) and var_items == slice(None):
data = X[:, :] data = X[:, :]
else: else:
data = X.multi_index[obs_items, var_items][''] data = X.multi_index[obs_items, var_items][""]
return data return data
def get_shape(self): def get_shape(self):
@@ -222,11 +218,9 @@ class CxgAdaptor(DataAdaptor):
# function to get the embedding # function to get the embedding
# this function to iterate through embeddings. # this function to iterate through embeddings.
def get_embedding_names(self): def get_embedding_names(self):
with ServerTiming.time(f'layout.lsuri'): with ServerTiming.time(f"layout.lsuri"):
pemb = self.get_path("emb") pemb = self.get_path("emb")
embeddings = [ embeddings = [os.path.basename(p) for (p, t) in self.lsuri(pemb) if t == "array"]
os.path.basename(p) for (p, t) in self.lsuri(pemb) if t == 'array'
]
return embeddings return embeddings
@staticmethod @staticmethod
@@ -235,82 +229,68 @@ class CxgAdaptor(DataAdaptor):
dtype = attr.dtype dtype = attr.dtype
schema = {} schema = {}
# type hints take precedence # type hints take precedence
if 'type' in type_hint: if "type" in type_hint:
schema['type'] = type_hint['type'] schema["type"] = type_hint["type"]
elif dtype == np.float32: elif dtype == np.float32:
schema['type'] = 'float32' schema["type"] = "float32"
elif dtype == np.int32: elif dtype == np.int32:
schema['type'] = 'int32' schema["type"] = "int32"
elif dtype == np.bool_: elif dtype == np.bool_:
schema['type'] = 'boolean' schema["type"] = "boolean"
elif dtype == np.str: elif dtype == np.str:
schema['type'] = 'string' schema["type"] = "string"
elif dtype == "category": elif dtype == "category":
schema["type"] = "categorical" schema["type"] = "categorical"
schema["categories"] = dtype.categories.tolist() schema["categories"] = dtype.categories.tolist()
else: else:
raise TypeError( raise TypeError(f"Annotations of type {dtype} are unsupported.")
f"Annotations of type {dtype} are unsupported."
)
if schema['type'] == 'categorical' and 'categories' in schema_hints: if schema["type"] == "categorical" and "categories" in schema_hints:
schema['categories'] = schema_hints['categories'] schema["categories"] = schema_hints["categories"]
return schema return schema
def get_schema(self): def get_schema(self):
shape = self.get_shape() shape = self.get_shape()
dtype = self.get_X_array_dtype() dtype = self.get_X_array_dtype()
dataframe = { dataframe = {"nObs": shape[0], "nVar": shape[1], "type": dtype.name}
'nObs': shape[0],
'nVar': shape[1],
'type': dtype.name
}
annotations = {} annotations = {}
for ax in ('obs', 'var'): for ax in ("obs", "var"):
A = self.open_array(ax) A = self.open_array(ax)
schema_hints = json.loads(A.meta['cxg_schema']) if 'cxg_schema' in A.meta else {} schema_hints = json.loads(A.meta["cxg_schema"]) if "cxg_schema" in A.meta else {}
if type(schema_hints) is not dict: if type(schema_hints) is not dict:
raise TypeError(f'Array schema was malformed.') raise TypeError(f"Array schema was malformed.")
cols = [] cols = []
for attr in A.schema: for attr in A.schema:
schema = dict(name=attr.name, writable=False) schema = dict(name=attr.name, writable=False)
type_hint = schema_hints.get(attr.name, {}) type_hint = schema_hints.get(attr.name, {})
# type hints take precedence # type hints take precedence
if 'type' in type_hint: if "type" in type_hint:
schema['type'] = type_hint['type'] schema["type"] = type_hint["type"]
if schema['type'] == 'categorical' and 'categories' in type_hint: if schema["type"] == "categorical" and "categories" in type_hint:
schema['categories'] = type_hint['categories'] schema["categories"] = type_hint["categories"]
else: else:
schema.update(dtype_to_schema(attr.dtype)) schema.update(dtype_to_schema(attr.dtype))
cols.append(schema) cols.append(schema)
annotations[ax] = dict(columns=cols) annotations[ax] = dict(columns=cols)
if 'index' in schema_hints: if "index" in schema_hints:
annotations[ax].update({'index': schema_hints['index']}) annotations[ax].update({"index": schema_hints["index"]})
obs_layout = [] obs_layout = []
embeddings = self.get_embedding_names() embeddings = self.get_embedding_names()
for ename in embeddings: for ename in embeddings:
A = self.open_array(f"emb/{ename}") A = self.open_array(f"emb/{ename}")
obs_layout.append({ obs_layout.append({"name": ename, "type": A.dtype.name, "dims": [f"{ename}_{d}" for d in range(0, A.ndim)]})
'name': ename,
'type': A.dtype.name,
'dims': [f'{ename}_{d}' for d in range(0, A.ndim)]
})
schema = { schema = {"dataframe": dataframe, "annotations": annotations, "layout": {"obs": obs_layout}}
'dataframe': dataframe,
'annotations': annotations,
'layout': {'obs': obs_layout}
}
return schema return schema
def annotation_to_fbs_matrix(self, axis, fields=None, labels=None): def annotation_to_fbs_matrix(self, axis, fields=None, labels=None):
with ServerTiming.time(f'annotations.{axis}.query'): with ServerTiming.time(f"annotations.{axis}.query"):
A = self.open_array(str(axis)) A = self.open_array(str(axis))
if fields is not None and len(fields) > 0: if fields is not None and len(fields) > 0:
try: try:
@@ -326,7 +306,7 @@ class CxgAdaptor(DataAdaptor):
obs_names = self.get_obs_names() obs_names = self.get_obs_names()
df = df.join(labels, obs_names) df = df.join(labels, obs_names)
with ServerTiming.time(f'annotations.{axis}.encode'): with ServerTiming.time(f"annotations.{axis}.encode"):
fbs = encode_matrix_fbs(df, col_idx=df.columns) fbs = encode_matrix_fbs(df, col_idx=df.columns)
return fbs return fbs
@@ -337,7 +317,7 @@ class CxgAdaptor(DataAdaptor):
if boolarray is None: if boolarray is None:
return slice(None) return slice(None)
assert type(boolarray) == np.ndarray assert type(boolarray) == np.ndarray
assert(boolarray.dtype) == bool assert (boolarray.dtype) == bool
selector = np.nonzero(boolarray)[0] selector = np.nonzero(boolarray)[0]
+50
View File
@@ -0,0 +1,50 @@
import shutil
import tempfile
from os import path
import pandas as pd
from server.common.annotations import AnnotationsLocalFile
from server.common.data_locator import DataLocator
from server.data_common.fbs.matrix import encode_matrix_fbs
from server.data_common.matrix_loader import MatrixDataLoader, MatrixDataType
def data_with_tmp_annotations(ext: MatrixDataType, annotations_fixture=False):
tmp_dir = tempfile.mkdtemp()
annotations_file = path.join(tmp_dir, "test_annotations.csv")
if annotations_fixture:
shutil.copyfile(f"test/test_datasets/pbmc3k-annotations.csv", annotations_file)
args = {
"layout": ["umap"],
"max_category_items": 100,
"obs_names": None,
"var_names": None,
"diffexp_lfc_cutoff": 0.01,
}
fname = {
MatrixDataType.H5AD: "../example-dataset/pbmc3k.h5ad",
MatrixDataType.CXG: "test/test_datasets/pbmc3k.cxg",
}[ext]
data_locator = DataLocator(fname)
data = MatrixDataLoader(data_locator.abspath()).open(args)
annotations = AnnotationsLocalFile(None, annotations_file)
return data, tmp_dir, annotations
def make_fbs(data):
df = pd.DataFrame(data)
return encode_matrix_fbs(matrix=df, row_idx=None, col_idx=df.columns)
def skip_if(condition, reason: str):
def decorator(f):
def wraps(self, *args, **kwargs):
if condition(self):
self.skipTest(reason)
else:
f(self, *args, **kwargs)
return wraps
return decorator
+1 -1
View File
@@ -3,7 +3,7 @@ from os import path
import pytest import pytest
import time import time
import unittest import unittest
import decode_fbs import server.test.decode_fbs as decode_fbs
from parameterized import parameterized_class from parameterized import parameterized_class
import numpy as np import numpy as np
+181 -40
View File
@@ -1,17 +1,24 @@
import shutil
import time
import unittest
from http import HTTPStatus from http import HTTPStatus
from subprocess import Popen from subprocess import Popen
import unittest
import time
import pandas as pd
import requests import requests
import decode_fbs import server.test.decode_fbs as decode_fbs
from server.test import skip_if, data_with_tmp_annotations, make_fbs
from server.data_common.matrix_loader import MatrixDataType
BAD_FILTER = {"filter": {"obs": {"annotation_value": [{"name": "xyz"}]}}} BAD_FILTER = {"filter": {"obs": {"annotation_value": [{"name": "xyz"}]}}}
# TODO (mweiden): remove ANNOTATIONS_ENABLED and Annotation subclasses when annotations are no longer experimental
# TODO (mweiden): remove MATRIX_DATA_TYPE and skip_if when user annotations for the CXG format is complete
class EndPoints(object): class EndPoints(object):
ANNOTATIONS_ENABLED = False
def setUp(self): def setUp(self):
self.session = requests.Session() self.session = requests.Session()
@@ -25,7 +32,9 @@ class EndPoints(object):
result_data = result.json() result_data = result.json()
self.assertEqual(result_data["schema"]["dataframe"]["nObs"], 2638) self.assertEqual(result_data["schema"]["dataframe"]["nObs"], 2638)
self.assertEqual(len(result_data["schema"]["annotations"]["obs"]), 2) self.assertEqual(len(result_data["schema"]["annotations"]["obs"]), 2)
self.assertEqual(len(result_data["schema"]["annotations"]["obs"]["columns"]), 5) self.assertEqual(
len(result_data["schema"]["annotations"]["obs"]["columns"]), 6 if self.ANNOTATIONS_ENABLED else 5
)
def test_config(self): def test_config(self):
endpoint = "config" endpoint = "config"
@@ -51,7 +60,7 @@ class EndPoints(object):
self.assertIsNotNone(df["columns"]) self.assertIsNotNone(df["columns"])
self.assertSetEqual( self.assertSetEqual(
set(df["col_idx"]), set(df["col_idx"]),
set(["pca_0", "pca_1", "tsne_0", "tsne_1", "umap_0", "umap_1", "draw_graph_fr_0", "draw_graph_fr_1"]), {"pca_0", "pca_1", "tsne_0", "tsne_1", "umap_0", "umap_1", "draw_graph_fr_0", "draw_graph_fr_1"},
) )
self.assertIsNone(df["row_idx"]) self.assertIsNone(df["row_idx"])
self.assertEqual(len(df["columns"]), df["n_cols"]) self.assertEqual(len(df["columns"]), df["n_cols"])
@@ -71,14 +80,21 @@ class EndPoints(object):
self.assertEqual(result.headers["Content-Type"], "application/octet-stream") self.assertEqual(result.headers["Content-Type"], "application/octet-stream")
df = decode_fbs.decode_matrix_FBS(result.content) df = decode_fbs.decode_matrix_FBS(result.content)
self.assertEqual(df["n_rows"], 2638) self.assertEqual(df["n_rows"], 2638)
self.assertEqual(df["n_cols"], 5) self.assertEqual(df["n_cols"], 6 if self.ANNOTATIONS_ENABLED else 5)
self.assertIsNotNone(df["columns"]) self.assertIsNotNone(df["columns"])
self.assertIsNotNone(df["col_idx"])
self.assertIsNone(df["row_idx"]) self.assertIsNone(df["row_idx"])
self.assertEqual(len(df["columns"]), df["n_cols"]) self.assertEqual(len(df["columns"]), df["n_cols"])
obs_index_col_name = self.schema["schema"]["annotations"]["obs"]["index"] obs_index_col_name = self.schema["schema"]["annotations"]["obs"]["index"]
self.assertListEqual(df["col_idx"], [obs_index_col_name, "n_genes", "percent_mito", "n_counts", "louvain"]) self.assertListEqual(
df["col_idx"],
[obs_index_col_name, "n_genes", "percent_mito", "n_counts", "louvain"]
+ (["cluster-test"] if self.ANNOTATIONS_ENABLED else []),
)
@skip_if(
lambda slf: hasattr(slf, "MATRIX_DATA_TYPE") and slf.MATRIX_DATA_TYPE == MatrixDataType.CXG,
"CXG file annotations are not feature-complete!",
)
def test_get_annotations_obs_keys_fbs(self): def test_get_annotations_obs_keys_fbs(self):
endpoint = "annotations/obs" endpoint = "annotations/obs"
query = "annotation-name=n_genes&annotation-name=percent_mito" query = "annotation-name=n_genes&annotation-name=percent_mito"
@@ -91,7 +107,6 @@ class EndPoints(object):
self.assertEqual(df["n_rows"], 2638) self.assertEqual(df["n_rows"], 2638)
self.assertEqual(df["n_cols"], 2) self.assertEqual(df["n_cols"], 2)
self.assertIsNotNone(df["columns"]) self.assertIsNotNone(df["columns"])
self.assertIsNotNone(df["col_idx"])
self.assertIsNone(df["row_idx"]) self.assertIsNone(df["row_idx"])
self.assertEqual(len(df["columns"]), df["n_cols"]) self.assertEqual(len(df["columns"]), df["n_cols"])
self.assertListEqual(df["col_idx"], ["n_genes", "percent_mito"]) self.assertListEqual(df["col_idx"], ["n_genes", "percent_mito"])
@@ -144,7 +159,6 @@ class EndPoints(object):
self.assertEqual(df["n_rows"], 1838) self.assertEqual(df["n_rows"], 1838)
self.assertEqual(df["n_cols"], 2) self.assertEqual(df["n_cols"], 2)
self.assertIsNotNone(df["columns"]) self.assertIsNotNone(df["columns"])
self.assertIsNotNone(df["col_idx"])
self.assertIsNone(df["row_idx"]) self.assertIsNone(df["row_idx"])
self.assertEqual(len(df["columns"]), df["n_cols"]) self.assertEqual(len(df["columns"]), df["n_cols"])
var_index_col_name = self.schema["schema"]["annotations"]["var"]["index"] var_index_col_name = self.schema["schema"]["annotations"]["var"]["index"]
@@ -162,7 +176,6 @@ class EndPoints(object):
self.assertEqual(df["n_rows"], 1838) self.assertEqual(df["n_rows"], 1838)
self.assertEqual(df["n_cols"], 1) self.assertEqual(df["n_cols"], 1)
self.assertIsNotNone(df["columns"]) self.assertIsNotNone(df["columns"])
self.assertIsNotNone(df["col_idx"])
self.assertIsNone(df["row_idx"]) self.assertIsNone(df["row_idx"])
self.assertEqual(len(df["columns"]), df["n_cols"]) self.assertEqual(len(df["columns"]), df["n_cols"])
self.assertListEqual(df["col_idx"], ["n_cells"]) self.assertListEqual(df["col_idx"], ["n_cells"])
@@ -215,7 +228,6 @@ class EndPoints(object):
self.assertEqual(df["n_rows"], 2638) self.assertEqual(df["n_rows"], 2638)
self.assertEqual(df["n_cols"], 3) self.assertEqual(df["n_cols"], 3)
self.assertIsNotNone(df["columns"]) self.assertIsNotNone(df["columns"])
self.assertIsNotNone(df["col_idx"])
self.assertIsNone(df["row_idx"]) self.assertIsNone(df["row_idx"])
self.assertEqual(len(df["columns"]), df["n_cols"]) self.assertEqual(len(df["columns"]), df["n_cols"])
self.assertListEqual(df["col_idx"].tolist(), [0, 1, 4]) self.assertListEqual(df["col_idx"].tolist(), [0, 1, 4])
@@ -240,6 +252,77 @@ class EndPoints(object):
result = self.session.get(url) result = self.session.get(url)
self.assertEqual(result.status_code, HTTPStatus.OK) self.assertEqual(result.status_code, HTTPStatus.OK)
@staticmethod
def _setUpClass(child_class, start_command):
child_class.ps = Popen(start_command)
child_class.session = requests.Session()
for i in range(90):
try:
result = child_class.session.get(f"{child_class.URL_BASE}schema")
child_class.schema = result.json()
except requests.exceptions.ConnectionError:
time.sleep(1)
@staticmethod
def _tearDownClass(child_class):
try:
child_class.ps.terminate()
except ProcessLookupError:
pass
class EndPointsAnnotations(EndPoints):
def test_get_schema_existing_writable(self):
self._test_get_schema_writable("cluster-test")
@skip_if(lambda slf: slf.MATRIX_DATA_TYPE == MatrixDataType.CXG, "CXG file annotations are not feature-complete!")
def test_get_user_annotations_existing_obs_keys_fbs(self):
self._test_get_user_annotations_obs_keys_fbs(
"cluster-test", {"unassigned", "one", "two", "three", "four", "five"},
)
@skip_if(lambda slf: slf.MATRIX_DATA_TYPE == MatrixDataType.CXG, "CXG file annotations are not feature-complete!")
def test_put_user_annotations_obs_fbs(self):
endpoint = "annotations/obs"
query = "annotation-collection-name=test_annotations"
url = f"{self.URL_BASE}{endpoint}?{query}"
n_rows = self.data.get_shape()[0]
fbs = make_fbs({"cat_A": pd.Series(["label_A" for l in range(0, n_rows)], dtype="category")})
result = self.session.put(url, data=fbs)
self.assertEqual(result.status_code, HTTPStatus.OK)
self.assertEqual(result.headers["Content-Type"], "application/json")
self.assertEqual(result.json(), {"status": "OK"})
self._test_get_schema_writable("cat_A")
self._test_get_user_annotations_obs_keys_fbs("cat_A", {"label_A"})
def _test_get_user_annotations_obs_keys_fbs(self, annotation_name, columns):
endpoint = "annotations/obs"
query = f"annotation-name={annotation_name}"
url = f"{self.URL_BASE}{endpoint}?{query}"
header = {"Accept": "application/octet-stream"}
result = self.session.get(url, headers=header)
self.assertEqual(result.status_code, HTTPStatus.OK)
self.assertEqual(result.headers["Content-Type"], "application/octet-stream")
df = decode_fbs.decode_matrix_FBS(result.content)
self.assertEqual(df["n_rows"], 2638)
self.assertEqual(df["n_cols"], 1)
self.assertListEqual(df["col_idx"], [annotation_name])
self.assertEqual(set(df["columns"][0]), columns)
self.assertIsNone(df["row_idx"])
self.assertEqual(len(df["columns"]), df["n_cols"])
def _test_get_schema_writable(self, cluster_name):
endpoint = "schema"
url = f"{self.URL_BASE}{endpoint}"
result = self.session.get(url)
self.assertEqual(result.status_code, HTTPStatus.OK)
self.assertEqual(result.headers["Content-Type"], "application/json")
result_data = result.json()
columns = result_data["schema"]["annotations"]["obs"]["columns"]
matching_columns = [c for c in columns if c["name"] == cluster_name]
self.assertEqual(len(matching_columns), 1)
self.assertTrue(matching_columns[0]["writable"])
class EndPointsAnndata(unittest.TestCase, EndPoints): class EndPointsAnndata(unittest.TestCase, EndPoints):
"""Test Case for endpoints""" """Test Case for endpoints"""
@@ -251,7 +334,8 @@ class EndPointsAnndata(unittest.TestCase, EndPoints):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
cls.ps = Popen( cls._setUpClass(
cls,
[ [
"cellxgene", "cellxgene",
"--no-upgrade-check", "--no-upgrade-check",
@@ -260,22 +344,16 @@ class EndPointsAnndata(unittest.TestCase, EndPoints):
"--verbose", "--verbose",
"--port", "--port",
str(cls.PORT), str(cls.PORT),
] ],
) )
cls.session = requests.Session()
for i in range(90):
try:
result = cls.session.get(f"{cls.URL_BASE}schema")
cls.schema = result.json()
except requests.exceptions.ConnectionError:
time.sleep(1)
@classmethod @classmethod
def tearDownClass(cls): def tearDownClass(cls):
try: cls._tearDownClass(cls)
cls.ps.terminate()
except ProcessLookupError: @property
pass def annotations_enabled(self):
return False
class EndPointsCxg(unittest.TestCase, EndPoints): class EndPointsCxg(unittest.TestCase, EndPoints):
@@ -288,28 +366,91 @@ class EndPointsCxg(unittest.TestCase, EndPoints):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
cls.ps = Popen( cls._setUpClass(
cls,
[ [
"cellxgene", "cellxgene",
"--no-upgrade-check", "--no-upgrade-check",
"launch", "launch",
"../example-dataset/pbmc3k.cxg", "test/test_datasets/pbmc3k.cxg",
"--verbose", "--verbose",
"--port", "--port",
str(cls.PORT), str(cls.PORT),
] ],
) )
cls.session = requests.Session()
for i in range(90):
try:
result = cls.session.get(f"{cls.URL_BASE}schema")
cls.schema = result.json()
except requests.exceptions.ConnectionError:
time.sleep(1)
@classmethod @classmethod
def tearDownClass(cls): def tearDownClass(cls):
try: cls._tearDownClass(cls)
cls.ps.terminate()
except ProcessLookupError:
pass class EndPointsAnndataAnnotations(unittest.TestCase, EndPointsAnnotations):
"""Test Case for endpoints"""
PORT = 5012
LOCAL_URL = f"http://127.0.0.1:{PORT}/"
VERSION = "v0.2"
URL_BASE = f"{LOCAL_URL}api/{VERSION}/"
ANNOTATIONS_ENABLED = True
MATRIX_DATA_TYPE = MatrixDataType.H5AD
@classmethod
def setUpClass(cls):
cls.data, cls.tmp_dir, cls.annotations = data_with_tmp_annotations(
MatrixDataType.H5AD, annotations_fixture=True
)
cls._setUpClass(
cls,
[
"cellxgene",
"--no-upgrade-check",
"launch",
"--experimental-annotations",
"--experimental-annotations-file",
cls.annotations.output_file,
"--verbose",
"--port",
str(cls.PORT),
cls.data.get_location(),
],
)
@classmethod
def tearDownClass(cls):
shutil.rmtree(cls.tmp_dir)
cls._tearDownClass(cls)
class EndPointsCxgAnnotations(unittest.TestCase, EndPointsAnnotations):
"""Test Case for endpoints"""
PORT = 5013
LOCAL_URL = f"http://127.0.0.1:{PORT}/"
VERSION = "v0.2"
URL_BASE = f"{LOCAL_URL}api/{VERSION}/"
ANNOTATIONS_ENABLED = True
MATRIX_DATA_TYPE = MatrixDataType.CXG
@classmethod
def setUpClass(cls):
cls.data, cls.tmp_dir, cls.annotations = data_with_tmp_annotations(MatrixDataType.CXG, annotations_fixture=True)
cls._setUpClass(
cls,
[
"cellxgene",
"--no-upgrade-check",
"launch",
"--experimental-annotations",
"--experimental-annotations-file",
cls.annotations.output_file,
"--verbose",
"--port",
str(cls.PORT),
cls.data.get_location(),
],
)
@classmethod
def tearDownClass(cls):
shutil.rmtree(cls.tmp_dir)
cls._tearDownClass(cls)
+1 -1
View File
@@ -3,7 +3,7 @@ import pandas as pd
import numpy as np import numpy as np
from scipy import sparse from scipy import sparse
import decode_fbs import server.test.decode_fbs as decode_fbs
from server.data_common.fbs.matrix import encode_matrix_fbs, decode_matrix_fbs from server.data_common.fbs.matrix import encode_matrix_fbs, decode_matrix_fbs
+1 -1
View File
@@ -3,7 +3,7 @@ import unittest
import warnings import warnings
import math import math
import decode_fbs import server.test.decode_fbs as decode_fbs
from server.data_anndata.anndata_adaptor import AnndataAdaptor from server.data_anndata.anndata_adaptor import AnndataAdaptor
from server.common.errors import FilterError from server.common.errors import FilterError
+1 -1
View File
@@ -4,7 +4,7 @@ import unittest
import time import time
import math import math
import decode_fbs import server.test.decode_fbs as decode_fbs
import requests import requests
+20 -39
View File
@@ -1,42 +1,23 @@
import json import json
from os import path, listdir from os import path, listdir
import unittest import unittest
import decode_fbs import server.test.decode_fbs as decode_fbs
import tempfile
import shutil import shutil
import numpy as np import numpy as np
import pandas as pd import pandas as pd
from server.data_anndata.anndata_adaptor import AnndataAdaptor
from server.data_common.fbs.matrix import encode_matrix_fbs
from server.common.data_locator import DataLocator
from server.common.annotations import AnnotationsLocalFile
from server.common.rest import schema_get_helper, annotations_put_fbs_helper from server.common.rest import schema_get_helper, annotations_put_fbs_helper
from server.test import data_with_tmp_annotations, make_fbs
from server.data_common.matrix_loader import MatrixDataType
class WritableAnnotationTest(unittest.TestCase): class WritableAnnotationTest(unittest.TestCase):
def setUp(self): def setUp(self):
self.tmpDir = tempfile.mkdtemp() self.data, self.tmp_dir, self.annotations = data_with_tmp_annotations(MatrixDataType.H5AD)
self.annotations_file = path.join(self.tmpDir, "test_annotations.csv")
args = {
"layout": ["umap"],
"max_category_items": 100,
"obs_names": None,
"var_names": None,
"diffexp_lfc_cutoff": 0.01,
}
fname = "../example-dataset/pbmc3k.h5ad"
data_locator = DataLocator(fname)
self.data = AnndataAdaptor(data_locator, args)
self.annotations = AnnotationsLocalFile(None, self.annotations_file)
def tearDown(self): def tearDown(self):
shutil.rmtree(self.tmpDir) shutil.rmtree(self.tmp_dir)
def make_fbs(self, data):
df = pd.DataFrame(data)
return encode_matrix_fbs(matrix=df, row_idx=None, col_idx=df.columns)
def annotation_put_fbs(self, fbs): def annotation_put_fbs(self, fbs):
annotations_put_fbs_helper(self.data, self.annotations, fbs) annotations_put_fbs_helper(self.data, self.annotations, fbs)
@@ -45,8 +26,8 @@ class WritableAnnotationTest(unittest.TestCase):
def test_error_checks(self): def test_error_checks(self):
# verify that the expected errors are generated # verify that the expected errors are generated
n_rows = self.data.data.obs.shape[0] n_rows = self.data.get_shape()[0]
fbs_bad = self.make_fbs({"louvain": pd.Series(["undefined" for l in range(0, n_rows)], dtype="category")}) fbs_bad = make_fbs({"louvain": pd.Series(["undefined" for l in range(0, n_rows)], dtype="category")})
# ensure we catch attempt to overwrite non-writable data # ensure we catch attempt to overwrite non-writable data
with self.assertRaises(KeyError): with self.assertRaises(KeyError):
@@ -54,8 +35,8 @@ class WritableAnnotationTest(unittest.TestCase):
def test_write_to_file(self): def test_write_to_file(self):
# verify the file is written as expected # verify the file is written as expected
n_rows = self.data.data.obs.shape[0] n_rows = self.data.get_shape()[0]
fbs = self.make_fbs( fbs = make_fbs(
{ {
"cat_A": pd.Series(["label_A" for l in range(0, n_rows)], dtype="category"), "cat_A": pd.Series(["label_A" for l in range(0, n_rows)], dtype="category"),
"cat_B": pd.Series(["label_B" for l in range(0, n_rows)], dtype="category"), "cat_B": pd.Series(["label_B" for l in range(0, n_rows)], dtype="category"),
@@ -63,8 +44,8 @@ class WritableAnnotationTest(unittest.TestCase):
) )
res = self.annotation_put_fbs(fbs) res = self.annotation_put_fbs(fbs)
self.assertEqual(res, json.dumps({"status": "OK"})) self.assertEqual(res, json.dumps({"status": "OK"}))
self.assertTrue(path.exists(self.annotations_file)) self.assertTrue(path.exists(self.annotations.output_file))
df = pd.read_csv(self.annotations_file, index_col=0, header=0, comment="#") df = pd.read_csv(self.annotations.output_file, index_col=0, header=0, comment="#")
self.assertEqual(df.shape, (n_rows, 2)) self.assertEqual(df.shape, (n_rows, 2))
self.assertEqual(set(df.columns), {"cat_A", "cat_B"}) self.assertEqual(set(df.columns), {"cat_A", "cat_B"})
self.assertTrue(self.data.original_obs_index.equals(df.index)) self.assertTrue(self.data.original_obs_index.equals(df.index))
@@ -72,7 +53,7 @@ class WritableAnnotationTest(unittest.TestCase):
self.assertTrue(np.all(df["cat_B"] == ["label_B" for l in range(0, n_rows)])) self.assertTrue(np.all(df["cat_B"] == ["label_B" for l in range(0, n_rows)]))
# verify complete overwrite on second attempt, AND rotation occurs # verify complete overwrite on second attempt, AND rotation occurs
fbs = self.make_fbs( fbs = make_fbs(
{ {
"cat_A": pd.Series(["label_A1" for l in range(0, n_rows)], dtype="category"), "cat_A": pd.Series(["label_A1" for l in range(0, n_rows)], dtype="category"),
"cat_C": pd.Series(["label_C" for l in range(0, n_rows)], dtype="category"), "cat_C": pd.Series(["label_C" for l in range(0, n_rows)], dtype="category"),
@@ -80,14 +61,14 @@ class WritableAnnotationTest(unittest.TestCase):
) )
res = self.annotation_put_fbs(fbs) res = self.annotation_put_fbs(fbs)
self.assertEqual(res, json.dumps({"status": "OK"})) self.assertEqual(res, json.dumps({"status": "OK"}))
self.assertTrue(path.exists(self.annotations_file)) self.assertTrue(path.exists(self.annotations.output_file))
df = pd.read_csv(self.annotations_file, index_col=0, header=0, comment="#") df = pd.read_csv(self.annotations.output_file, index_col=0, header=0, comment="#")
self.assertEqual(set(df.columns), {"cat_A", "cat_C"}) self.assertEqual(set(df.columns), {"cat_A", "cat_C"})
self.assertTrue(np.all(df["cat_A"] == ["label_A1" for l in range(0, n_rows)])) self.assertTrue(np.all(df["cat_A"] == ["label_A1" for l in range(0, n_rows)]))
self.assertTrue(np.all(df["cat_C"] == ["label_C" for l in range(0, n_rows)])) self.assertTrue(np.all(df["cat_C"] == ["label_C" for l in range(0, n_rows)]))
# rotation # rotation
name, ext = path.splitext(self.annotations_file) name, ext = path.splitext(self.annotations.output_file)
backup_dir = f"{name}-backups" backup_dir = f"{name}-backups"
self.assertTrue(path.isdir(backup_dir)) self.assertTrue(path.isdir(backup_dir))
found_files = listdir(backup_dir) found_files = listdir(backup_dir)
@@ -95,8 +76,8 @@ class WritableAnnotationTest(unittest.TestCase):
def test_file_rotation_to_max_9(self): def test_file_rotation_to_max_9(self):
# verify we stop rotation at 9 # verify we stop rotation at 9
n_rows = self.data.data.obs.shape[0] n_rows = self.data.get_shape()[0]
fbs = self.make_fbs( fbs = make_fbs(
{ {
"cat_A": pd.Series(["label_A" for l in range(0, n_rows)], dtype="category"), "cat_A": pd.Series(["label_A" for l in range(0, n_rows)], dtype="category"),
"cat_B": pd.Series(["label_B" for l in range(0, n_rows)], dtype="category"), "cat_B": pd.Series(["label_B" for l in range(0, n_rows)], dtype="category"),
@@ -106,7 +87,7 @@ class WritableAnnotationTest(unittest.TestCase):
res = self.annotation_put_fbs(fbs) res = self.annotation_put_fbs(fbs)
self.assertEqual(res, json.dumps({"status": "OK"})) self.assertEqual(res, json.dumps({"status": "OK"}))
name, ext = path.splitext(self.annotations_file) name, ext = path.splitext(self.annotations.output_file)
backup_dir = f"{name}-backups" backup_dir = f"{name}-backups"
self.assertTrue(path.isdir(backup_dir)) self.assertTrue(path.isdir(backup_dir))
found_files = listdir(backup_dir) found_files = listdir(backup_dir)
@@ -116,8 +97,8 @@ class WritableAnnotationTest(unittest.TestCase):
# verify that OBS PUTs (annotation_put_fbs) are accessible via # verify that OBS PUTs (annotation_put_fbs) are accessible via
# GET (annotation_to_fbs_matrix) # GET (annotation_to_fbs_matrix)
n_rows = self.data.data.obs.shape[0] n_rows = self.data.get_shape()[0]
fbs = self.make_fbs( fbs = make_fbs(
{ {
"cat_A": pd.Series(["label_A" for l in range(0, n_rows)], dtype="category"), "cat_A": pd.Series(["label_A" for l in range(0, n_rows)], dtype="category"),
"cat_B": pd.Series(["label_B" for l in range(0, n_rows)], dtype="category"), "cat_B": pd.Series(["label_B" for l in range(0, n_rows)], dtype="category"),