mirror of
https://github.com/chanzuckerberg/cellxgene.git
synced 2026-10-07 10:08:12 +08:00
Create hosted user annotations [1685] (#1726)
* add function to retrieve latest annotation from db, db updates * read and write tiledb arrays * adding tests
This commit is contained in:
@@ -0,0 +1,78 @@
|
|||||||
|
from abc import ABCMeta, abstractmethod
|
||||||
|
|
||||||
|
import fastobo
|
||||||
|
import fsspec
|
||||||
|
from server.common.errors import OntologyLoadFailure
|
||||||
|
|
||||||
|
from server.common.utils import series_to_schema
|
||||||
|
|
||||||
|
|
||||||
|
class Annotations(metaclass=ABCMeta):
|
||||||
|
""" baseclass for annotations, including ontologies"""
|
||||||
|
|
||||||
|
""" 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):
|
||||||
|
self.ontology_data = None
|
||||||
|
|
||||||
|
def load_ontology(self, path):
|
||||||
|
"""Load and parse ontologies - currently support OBO files only."""
|
||||||
|
if path is None:
|
||||||
|
path = self.DefaultOnotology
|
||||||
|
|
||||||
|
try:
|
||||||
|
with fsspec.open(path) as f:
|
||||||
|
obo = fastobo.iter(f)
|
||||||
|
terms = filter(lambda stanza: type(stanza) is fastobo.term.TermFrame, obo)
|
||||||
|
names = [tag.name for term in terms for tag in term if type(tag) is fastobo.term.NameClause]
|
||||||
|
self.ontology_data = names
|
||||||
|
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
raise OntologyLoadFailure("Unable to find OBO ontology path") from e
|
||||||
|
|
||||||
|
except SyntaxError as e:
|
||||||
|
raise OntologyLoadFailure("Syntax error loading OBO ontology") from e
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise OntologyLoadFailure("Error loading OBO file") from e
|
||||||
|
|
||||||
|
def get_schema(self, data_adaptor):
|
||||||
|
schema = []
|
||||||
|
labels = self.read_labels(data_adaptor)
|
||||||
|
if labels is not None and not labels.empty:
|
||||||
|
for col in labels.columns:
|
||||||
|
col_schema = dict(name=col, writable=True)
|
||||||
|
col_schema.update(series_to_schema(labels[col]))
|
||||||
|
schema.append(col_schema)
|
||||||
|
|
||||||
|
return schema
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def set_collection(self, name):
|
||||||
|
"""set or create a new annotation collection"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def read_labels(self, data_adaptor):
|
||||||
|
"""Return the labels as a pandas.DataFrame"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def write_labels(self, df, data_adaptor):
|
||||||
|
"""Write the labels (df) to a persistent storage such that it can later be read"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def update_parameters(self, parameters, data_adaptor):
|
||||||
|
"""Update configuration parameters that describe information about the annotations feature"""
|
||||||
|
params = {}
|
||||||
|
params["annotations"] = True
|
||||||
|
|
||||||
|
if self.ontology_data:
|
||||||
|
params["annotations_cell_ontology_enabled"] = True
|
||||||
|
params["annotations_cell_ontology_terms"] = self.ontology_data
|
||||||
|
else:
|
||||||
|
params["annotations_cell_ontology_enabled"] = False
|
||||||
|
|
||||||
|
parameters.update(params)
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import tiledb
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
from server.common.annotations.annotations import Annotations
|
||||||
|
from server.converters.cxgtool import sanitize_keys, generate_schema_hints_and_convert_value_types, cxg_dtype
|
||||||
|
from server.db.cellxgene_orm import CellxGeneDataset, Annotation
|
||||||
|
|
||||||
|
|
||||||
|
class AnnotationsHostedTileDB(Annotations):
|
||||||
|
CXG_ANNO_COLLECTION = "cxg_anno_collection"
|
||||||
|
|
||||||
|
def __init__(self, directory_path, db):
|
||||||
|
super().__init__()
|
||||||
|
self.db = db
|
||||||
|
self.directory_path = directory_path
|
||||||
|
|
||||||
|
def check_category_names(self, df):
|
||||||
|
sanitize_keys(df.keys().to_list(), False)
|
||||||
|
|
||||||
|
def is_safe_collection_name(self, name):
|
||||||
|
"""
|
||||||
|
return true if this is a safe collection name
|
||||||
|
this is ultra conservative. If we want to allow full legal file name syntax,
|
||||||
|
we could look at modules like `pathvalidate`
|
||||||
|
"""
|
||||||
|
if name is None:
|
||||||
|
return False
|
||||||
|
return re.match(r"^[\w\-]+$", name) is not None
|
||||||
|
|
||||||
|
def set_collection(self, name):
|
||||||
|
self.CXG_ANNO_COLLECTION = name
|
||||||
|
|
||||||
|
def read_labels(self, data_adaptor):
|
||||||
|
user_id = current_app.auth.get_user_id()
|
||||||
|
dataset_name = data_adaptor.get_location()
|
||||||
|
dataset_id = str(self.db.query(
|
||||||
|
table_args=[CellxGeneDataset],
|
||||||
|
filter_args=[CellxGeneDataset.name == dataset_name]
|
||||||
|
)[0].id)
|
||||||
|
|
||||||
|
annotation_object = self.db.query_for_most_recent(
|
||||||
|
Annotation, [Annotation.user_id == user_id, Annotation.dataset_id == dataset_id]
|
||||||
|
)
|
||||||
|
if annotation_object:
|
||||||
|
df = tiledb.open(annotation_object.tiledb_uri)
|
||||||
|
pandas_df = self.convert_to_pandas_df(df)
|
||||||
|
return pandas_df
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def convert_to_pandas_df(self, tileDBArray):
|
||||||
|
repr_meta = None
|
||||||
|
index_dims = None
|
||||||
|
if '__pandas_attribute_repr' in tileDBArray.meta:
|
||||||
|
# backwards compatibility... unsure if necessary at this point
|
||||||
|
repr_meta = json.loads(tileDBArray.meta['__pandas_attribute_repr'])
|
||||||
|
if '__pandas_index_dims' in tileDBArray.meta:
|
||||||
|
index_dims = json.loads(tileDBArray.meta['__pandas_index_dims'])
|
||||||
|
|
||||||
|
data = tileDBArray[:]
|
||||||
|
indexes = list()
|
||||||
|
|
||||||
|
for col_name, col_val in data.items():
|
||||||
|
if repr_meta and col_name in repr_meta:
|
||||||
|
new_col = pd.Series(col_val, dtype=repr_meta[col_name])
|
||||||
|
data[col_name] = new_col
|
||||||
|
elif index_dims and col_name in index_dims:
|
||||||
|
new_col = pd.Series(col_val, dtype=index_dims[col_name])
|
||||||
|
data[col_name] = new_col
|
||||||
|
indexes.append(col_name)
|
||||||
|
|
||||||
|
new_df = pd.DataFrame.from_dict(data)
|
||||||
|
if len(indexes) > 0:
|
||||||
|
new_df.set_index(indexes, inplace=True)
|
||||||
|
|
||||||
|
return new_df
|
||||||
|
|
||||||
|
def write_labels(self, df, data_adaptor):
|
||||||
|
|
||||||
|
user_id = current_app.auth.get_user_id()
|
||||||
|
timestamp = time.time()
|
||||||
|
dataset_name = data_adaptor.get_location()
|
||||||
|
dataset_id = self.db.get_or_create_dataset(dataset_name)
|
||||||
|
user_id = self.db.get_or_create_user(user_id)
|
||||||
|
|
||||||
|
uri = f"{self.directory_path}-{dataset_name}-{user_id}-{timestamp}"
|
||||||
|
if uri.startswith("s3://"):
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
os.makedirs(uri, exist_ok=True)
|
||||||
|
schema_hints, values = generate_schema_hints_and_convert_value_types(df)
|
||||||
|
|
||||||
|
annotation = Annotation(
|
||||||
|
tiledb_uri=uri,
|
||||||
|
user_id=user_id,
|
||||||
|
dataset_id=str(dataset_id),
|
||||||
|
schema_hints=json.dumps(schema_hints)
|
||||||
|
)
|
||||||
|
if not df.empty:
|
||||||
|
self.check_category_names(df)
|
||||||
|
# convert to tiledb datatypes
|
||||||
|
for col in df:
|
||||||
|
df[col] = df[col].astype(cxg_dtype(df[col]))
|
||||||
|
tiledb.from_pandas(uri, df)
|
||||||
|
|
||||||
|
self.db.session.add(annotation)
|
||||||
|
self.db.session.commit()
|
||||||
@@ -1,86 +1,16 @@
|
|||||||
import json
|
|
||||||
import uuid
|
|
||||||
import time
|
|
||||||
from datetime import datetime
|
|
||||||
import re
|
|
||||||
import os
|
|
||||||
import pandas as pd
|
|
||||||
from hashlib import blake2b
|
|
||||||
import base64
|
import base64
|
||||||
from server import __version__ as cellxgene_version
|
import os
|
||||||
|
import re
|
||||||
import threading
|
import threading
|
||||||
from server.common.errors import AnnotationsError, OntologyLoadFailure
|
from datetime import datetime
|
||||||
from server.common.utils import series_to_schema
|
from hashlib import blake2b
|
||||||
import fsspec
|
|
||||||
import fastobo
|
|
||||||
from flask import session, current_app, has_request_context
|
|
||||||
from abc import ABCMeta, abstractmethod
|
|
||||||
|
|
||||||
from server.db.cellxgene_orm import CellxGeneDataset, Annotation
|
import pandas as pd
|
||||||
from server.db.db_utils import DbUtils
|
from flask import session, has_request_context, current_app
|
||||||
|
|
||||||
|
from server import __version__ as cellxgene_version
|
||||||
class Annotations(metaclass=ABCMeta):
|
from server.common.annotations.annotations import Annotations
|
||||||
""" baseclass for annotations, including ontologies"""
|
from server.common.errors import AnnotationsError
|
||||||
|
|
||||||
""" 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):
|
|
||||||
self.ontology_data = None
|
|
||||||
|
|
||||||
def load_ontology(self, path):
|
|
||||||
"""Load and parse ontologies - currently support OBO files only."""
|
|
||||||
if path is None:
|
|
||||||
path = self.DefaultOnotology
|
|
||||||
|
|
||||||
try:
|
|
||||||
with fsspec.open(path) as f:
|
|
||||||
obo = fastobo.iter(f)
|
|
||||||
terms = filter(lambda stanza: type(stanza) is fastobo.term.TermFrame, obo)
|
|
||||||
names = [tag.name for term in terms for tag in term if type(tag) is fastobo.term.NameClause]
|
|
||||||
self.ontology_data = names
|
|
||||||
|
|
||||||
except FileNotFoundError as e:
|
|
||||||
raise OntologyLoadFailure("Unable to find OBO ontology path") from e
|
|
||||||
|
|
||||||
except SyntaxError as e:
|
|
||||||
raise OntologyLoadFailure("Syntax error loading OBO ontology") from e
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
raise OntologyLoadFailure("Error loading OBO file") from e
|
|
||||||
|
|
||||||
def get_schema(self, data_adaptor):
|
|
||||||
schema = []
|
|
||||||
labels = self.read_labels(data_adaptor)
|
|
||||||
if labels is not None and not labels.empty:
|
|
||||||
for col in labels.columns:
|
|
||||||
col_schema = dict(name=col, writable=True)
|
|
||||||
col_schema.update(series_to_schema(labels[col]))
|
|
||||||
schema.append(col_schema)
|
|
||||||
|
|
||||||
return schema
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def set_collection(self, name):
|
|
||||||
"""set or create a new annotation collection"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def read_labels(self, data_adaptor):
|
|
||||||
"""Return the labels as a pandas.DataFrame"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def write_labels(self, df, data_adaptor):
|
|
||||||
"""Write the labels (df) to a persistent storage such that it can later be read"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def update_parameters(self, parameters, data_adaptor):
|
|
||||||
"""Update configuration parameters that describe information about the annotations feature"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class AnnotationsLocalFile(Annotations):
|
class AnnotationsLocalFile(Annotations):
|
||||||
@@ -101,7 +31,6 @@ class AnnotationsLocalFile(Annotations):
|
|||||||
def is_safe_collection_name(self, name):
|
def is_safe_collection_name(self, name):
|
||||||
"""
|
"""
|
||||||
return true if this is a safe collection name
|
return true if this is a safe collection name
|
||||||
|
|
||||||
this is ultra conservative. If we want to allow full legal file name syntax,
|
this is ultra conservative. If we want to allow full legal file name syntax,
|
||||||
we could look at modules like `pathvalidate`
|
we could look at modules like `pathvalidate`
|
||||||
"""
|
"""
|
||||||
@@ -265,55 +194,3 @@ class AnnotationsLocalFile(Annotations):
|
|||||||
params["annotations-data-collection-name"] = collection
|
params["annotations-data-collection-name"] = collection
|
||||||
|
|
||||||
parameters.update(params)
|
parameters.update(params)
|
||||||
|
|
||||||
|
|
||||||
class AnnotationsHostedTileDB(Annotations):
|
|
||||||
def __init__(self, directory_path: str, db: DbUtils):
|
|
||||||
super().__init__()
|
|
||||||
self.db = db
|
|
||||||
self.directory_path = directory_path
|
|
||||||
|
|
||||||
def set_collection(self, name):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def read_labels(self, data_adaptor):
|
|
||||||
uid = current_app.auth.get_user_id()
|
|
||||||
dataset_name = data_adaptor.get_location()
|
|
||||||
dataset = self.db.query(table_args=[CellxGeneDataset], filter_args=[CellxGeneDataset.name == dataset_name])
|
|
||||||
# Todo @madison retrieve latest based on timestamp
|
|
||||||
annotation_object = self.db.query_for_most_recent( # noqa F841
|
|
||||||
Annotation, [Annotation.user_id == uid, Annotation.dataset == dataset]
|
|
||||||
)
|
|
||||||
# Todo in future pr, retrieve dataframe from tiledb uri
|
|
||||||
|
|
||||||
def write_labels(self, df, data_adaptor):
|
|
||||||
uid = current_app.auth.get_user_id()
|
|
||||||
timestamp = time.time()
|
|
||||||
dataset_name = data_adaptor.get_location()
|
|
||||||
try:
|
|
||||||
dataset_id = self.db.query(
|
|
||||||
table_args=[CellxGeneDataset], filter_args=[CellxGeneDataset.name == dataset_name]
|
|
||||||
)[0].id
|
|
||||||
except IndexError:
|
|
||||||
dataset_id = uuid.uuid4()
|
|
||||||
dataset = CellxGeneDataset(id=dataset_id, name=dataset_name)
|
|
||||||
self.db.session.add(dataset)
|
|
||||||
|
|
||||||
uri = f"{self.directory_path}/{dataset_name}/{uid}/{timestamp}"
|
|
||||||
if "s3" in uri:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
os.makedirs(uri, exist_ok=True)
|
|
||||||
schema_hints = {}
|
|
||||||
annotation = Annotation(
|
|
||||||
tiledb_uri=uri,
|
|
||||||
user_id=uid,
|
|
||||||
dataset_id=str(dataset_id),
|
|
||||||
schema_hints=json.dumps(schema_hints)
|
|
||||||
)
|
|
||||||
# todo in future pr -- write df to tiledb, store at uri
|
|
||||||
self.db.session.add(annotation)
|
|
||||||
self.db.session.commit()
|
|
||||||
|
|
||||||
def update_parameters(self, parameters, data_adaptor):
|
|
||||||
pass
|
|
||||||
+45
-32
@@ -12,11 +12,13 @@ from server.common.errors import ConfigurationError, DatasetAccessError, Ontolog
|
|||||||
from server.data_common.matrix_loader import MatrixDataLoader, MatrixDataCacheManager, MatrixDataType
|
from server.data_common.matrix_loader import MatrixDataLoader, MatrixDataCacheManager, MatrixDataType
|
||||||
from server.common.utils import find_available_port, is_port_available
|
from server.common.utils import find_available_port, is_port_available
|
||||||
import warnings
|
import warnings
|
||||||
from server.common.annotations import AnnotationsLocalFile
|
from server.common.annotations.hosted_tiledb import AnnotationsHostedTileDB
|
||||||
|
from server.common.annotations.local_file_csv import AnnotationsLocalFile
|
||||||
from server.common.utils import custom_format_warning
|
from server.common.utils import custom_format_warning
|
||||||
import server.compute.diffexp_cxg as diffexp_tiledb
|
import server.compute.diffexp_cxg as diffexp_tiledb
|
||||||
from server.common.data_locator import discover_s3_region_name
|
from server.common.data_locator import discover_s3_region_name
|
||||||
from server.auth.auth import AuthTypeFactory
|
from server.auth.auth import AuthTypeFactory
|
||||||
|
from server.db.db_utils import DbUtils
|
||||||
|
|
||||||
DEFAULT_SERVER_PORT = 5005
|
DEFAULT_SERVER_PORT = 5005
|
||||||
# anything bigger than this will generate a special message
|
# anything bigger than this will generate a special message
|
||||||
@@ -277,6 +279,7 @@ class AppConfig(object):
|
|||||||
"is_authenticated": auth.is_user_authenticated(),
|
"is_authenticated": auth.is_user_authenticated(),
|
||||||
"requires_client_login": auth.requires_client_login(),
|
"requires_client_login": auth.requires_client_login(),
|
||||||
"username": auth.get_user_name(),
|
"username": auth.get_user_name(),
|
||||||
|
"user_id": auth.get_user_id()
|
||||||
}
|
}
|
||||||
if auth.requires_client_login():
|
if auth.requires_client_login():
|
||||||
config["authentication"].update({
|
config["authentication"].update({
|
||||||
@@ -736,6 +739,8 @@ class DatasetConfig(BaseConfig):
|
|||||||
self.user_annotations__local_file_csv__file = dc["user_annotations"]["local_file_csv"]["file"]
|
self.user_annotations__local_file_csv__file = dc["user_annotations"]["local_file_csv"]["file"]
|
||||||
self.user_annotations__ontology__enable = dc["user_annotations"]["ontology"]["enable"]
|
self.user_annotations__ontology__enable = dc["user_annotations"]["ontology"]["enable"]
|
||||||
self.user_annotations__ontology__obo_location = dc["user_annotations"]["ontology"]["obo_location"]
|
self.user_annotations__ontology__obo_location = dc["user_annotations"]["ontology"]["obo_location"]
|
||||||
|
self.user_annotations__hosted_tiledb_array__db_uri = dc["user_annotations"]["hosted_tiledb_array"]["db_uri"]
|
||||||
|
self.user_annotations__hosted_tiledb_array__hosted_file_directory = dc["user_annotations"]["hosted_tiledb_array"]["hosted_file_directory"] # noqa E501
|
||||||
|
|
||||||
self.embeddings__names = dc["embeddings"]["names"]
|
self.embeddings__names = dc["embeddings"]["names"]
|
||||||
self.embeddings__enable_reembedding = dc["embeddings"]["enable_reembedding"]
|
self.embeddings__enable_reembedding = dc["embeddings"]["enable_reembedding"]
|
||||||
@@ -786,6 +791,8 @@ class DatasetConfig(BaseConfig):
|
|||||||
self.check_attr("user_annotations__local_file_csv__file", (type(None), str))
|
self.check_attr("user_annotations__local_file_csv__file", (type(None), str))
|
||||||
self.check_attr("user_annotations__ontology__enable", bool)
|
self.check_attr("user_annotations__ontology__enable", bool)
|
||||||
self.check_attr("user_annotations__ontology__obo_location", (type(None), str))
|
self.check_attr("user_annotations__ontology__obo_location", (type(None), str))
|
||||||
|
self.check_attr("user_annotations__hosted_tiledb_array__db_uri", (type(None), str))
|
||||||
|
self.check_attr("user_annotations__hosted_tiledb_array__hosted_file_directory", (type(None), str))
|
||||||
|
|
||||||
if self.user_annotations__enable:
|
if self.user_annotations__enable:
|
||||||
server_config = self.app_config.server_config
|
server_config = self.app_config.server_config
|
||||||
@@ -797,43 +804,49 @@ class DatasetConfig(BaseConfig):
|
|||||||
|
|
||||||
# TODO, replace this with a factory pattern once we have more than one way
|
# TODO, replace this with a factory pattern once we have more than one way
|
||||||
# to do annotations. currently only local_file_csv
|
# to do annotations. currently only local_file_csv
|
||||||
if self.user_annotations__type != "local_file_csv":
|
if self.user_annotations__type == "local_file_csv":
|
||||||
raise ConfigurationError('The only annotation type support is "local_file_csv"')
|
dirname = self.user_annotations__local_file_csv__directory
|
||||||
|
filename = self.user_annotations__local_file_csv__file
|
||||||
|
|
||||||
dirname = self.user_annotations__local_file_csv__directory
|
if filename is not None and dirname is not None:
|
||||||
filename = self.user_annotations__local_file_csv__file
|
raise ConfigurationError("'annotations-file' and 'annotations-dir' may not be used together.")
|
||||||
|
|
||||||
if filename is not None and dirname is not None:
|
if filename is not None:
|
||||||
raise ConfigurationError("'annotations-file' and 'annotations-dir' may not be used together.")
|
lf_name, lf_ext = splitext(filename)
|
||||||
|
if lf_ext and lf_ext != ".csv":
|
||||||
|
raise ConfigurationError(f"annotation file type must be .csv: {filename}")
|
||||||
|
|
||||||
if filename is not None:
|
if dirname is not None and not isdir(dirname):
|
||||||
lf_name, lf_ext = splitext(filename)
|
try:
|
||||||
if lf_ext and lf_ext != ".csv":
|
os.mkdir(dirname)
|
||||||
raise ConfigurationError(f"annotation file type must be .csv: {filename}")
|
except OSError:
|
||||||
|
raise ConfigurationError("Unable to create directory specified by --annotations-dir")
|
||||||
|
|
||||||
if dirname is not None and not isdir(dirname):
|
self.user_annotations = AnnotationsLocalFile(dirname, filename)
|
||||||
try:
|
|
||||||
os.mkdir(dirname)
|
|
||||||
except OSError:
|
|
||||||
raise ConfigurationError("Unable to create directory specified by --annotations-dir")
|
|
||||||
|
|
||||||
self.user_annotations = AnnotationsLocalFile(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.
|
||||||
# if the user has specified a fixed label file, go ahead and validate it
|
server_config = self.app_config.server_config
|
||||||
# so that we can remove errors early in the process.
|
if server_config.single_dataset__datapath and self.user_annotations__local_file_csv__file:
|
||||||
server_config = self.app_config.server_config
|
with server_config.matrix_data_cache_manager.data_adaptor(
|
||||||
if server_config.single_dataset__datapath and self.user_annotations__local_file_csv__file:
|
self.tag, server_config.single_dataset__datapath, self.app_config
|
||||||
with server_config.matrix_data_cache_manager.data_adaptor(
|
) as data_adaptor:
|
||||||
self.tag, server_config.single_dataset__datapath, self.app_config
|
data_adaptor.check_new_labels(self.user_annotations.read_labels(data_adaptor))
|
||||||
) as data_adaptor:
|
|
||||||
data_adaptor.check_new_labels(self.user_annotations.read_labels(data_adaptor))
|
|
||||||
|
|
||||||
if self.user_annotations__ontology__enable or self.user_annotations__ontology__obo_location:
|
|
||||||
try:
|
|
||||||
self.user_annotations.load_ontology(self.user_annotations__ontology__obo_location)
|
|
||||||
except OntologyLoadFailure as e:
|
|
||||||
raise ConfigurationError("Unable to load ontology terms\n" + str(e))
|
|
||||||
|
|
||||||
|
if self.user_annotations__ontology__enable or self.user_annotations__ontology__obo_location:
|
||||||
|
try:
|
||||||
|
self.user_annotations.load_ontology(self.user_annotations__ontology__obo_location)
|
||||||
|
except OntologyLoadFailure as e:
|
||||||
|
raise ConfigurationError("Unable to load ontology terms\n" + str(e))
|
||||||
|
elif self.user_annotations__type == "hosted_tiledb_array":
|
||||||
|
self.check_attr("user_annotations__hosted_tiledb_array__db_uri", str)
|
||||||
|
self.check_attr("user_annotations__hosted_tiledb_array__hosted_file_directory", str)
|
||||||
|
self.user_annotations = AnnotationsHostedTileDB(
|
||||||
|
directory_path=self.user_annotations__hosted_tiledb_array__hosted_file_directory,
|
||||||
|
db=DbUtils(self.user_annotations__hosted_tiledb_array__db_uri),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise ConfigurationError('The only annotation type support is "local_file_csv" or "hosted_tiledb_array')
|
||||||
else:
|
else:
|
||||||
if self.user_annotations__type == "local_file_csv":
|
if self.user_annotations__type == "local_file_csv":
|
||||||
dirname = self.user_annotations__local_file_csv__directory
|
dirname = self.user_annotations__local_file_csv__directory
|
||||||
|
|||||||
@@ -171,6 +171,9 @@ dataset:
|
|||||||
user_annotations:
|
user_annotations:
|
||||||
enable: true
|
enable: true
|
||||||
type: local_file_csv
|
type: local_file_csv
|
||||||
|
hosted_tiledb_array:
|
||||||
|
db_uri: null
|
||||||
|
hosted_file_directory: null
|
||||||
local_file_csv:
|
local_file_csv:
|
||||||
directory: null
|
directory: null
|
||||||
file: null
|
file: null
|
||||||
|
|||||||
@@ -46,6 +46,11 @@ define_request_exception(
|
|||||||
"Raised when there is an authentication error",
|
"Raised when there is an authentication error",
|
||||||
default_status_code=HTTPStatus.UNAUTHORIZED)
|
default_status_code=HTTPStatus.UNAUTHORIZED)
|
||||||
|
|
||||||
|
define_request_exception(
|
||||||
|
"AnnotationCategoryNameError",
|
||||||
|
"Raised when an annotation category name cant be saved",
|
||||||
|
default_status_code=HTTPStatus.UNPROCESSABLE_ENTITY)
|
||||||
|
|
||||||
define_exception("OntologyLoadFailure", "Raised when reading the ontology file fails")
|
define_exception("OntologyLoadFailure", "Raised when reading the ontology file fails")
|
||||||
define_exception("ConfigurationError", "Raised when checking configuration errors")
|
define_exception("ConfigurationError", "Raised when checking configuration errors")
|
||||||
define_exception("PrepareError", "Raised when data is misprepared")
|
define_exception("PrepareError", "Raised when data is misprepared")
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import json
|
|||||||
from scipy.stats import mode
|
from scipy.stats import mode
|
||||||
|
|
||||||
from server.common.colors import convert_anndata_category_colors_to_cxg_category_colors
|
from server.common.colors import convert_anndata_category_colors_to_cxg_category_colors
|
||||||
from server.common.errors import ColorFormatException
|
from server.common.errors import ColorFormatException, AnnotationCategoryNameError
|
||||||
from server.common.corpora import (
|
from server.common.corpora import (
|
||||||
corpora_get_props_from_anndata,
|
corpora_get_props_from_anndata,
|
||||||
corpora_get_versions_from_anndata,
|
corpora_get_versions_from_anndata,
|
||||||
@@ -291,20 +291,26 @@ def alias_index_col(df, df_name, index_col_name):
|
|||||||
return (df, index_col_name)
|
return (df, index_col_name)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_schema_hints_and_convert_value_types(df):
|
||||||
|
value = {}
|
||||||
|
schema_hints = {}
|
||||||
|
for k, v in df.items():
|
||||||
|
dtype, hints = cxg_type(v)
|
||||||
|
value[k] = v.to_numpy(dtype=dtype)
|
||||||
|
if hints:
|
||||||
|
schema_hints.update({k: hints})
|
||||||
|
return schema_hints, value
|
||||||
|
|
||||||
|
|
||||||
def save_dataframe(container, name, df, index_col_name, ctx):
|
def save_dataframe(container, name, df, index_col_name, ctx):
|
||||||
A_name = f"{container}/{name}"
|
A_name = f"{container}/{name}"
|
||||||
(df, index_col_name) = alias_index_col(df, name, index_col_name)
|
(df, index_col_name) = alias_index_col(df, name, index_col_name)
|
||||||
create_dataframe(A_name, df, ctx=ctx)
|
create_dataframe(A_name, df, ctx=ctx)
|
||||||
with tiledb.DenseArray(A_name, mode="w", ctx=ctx) as A:
|
with tiledb.DenseArray(A_name, mode="w", ctx=ctx) as A:
|
||||||
value = {}
|
schema_hints, value = generate_schema_hints_and_convert_value_types(df)
|
||||||
schema_hints = {}
|
|
||||||
for k, v in df.items():
|
|
||||||
dtype, hints = cxg_type(v)
|
|
||||||
value[k] = v.to_numpy(dtype=dtype)
|
|
||||||
if hints:
|
|
||||||
schema_hints.update({k: hints})
|
|
||||||
|
|
||||||
schema_hints.update({"index": index_col_name})
|
schema_hints.update({"index": index_col_name})
|
||||||
|
# convert all values in all cols to a numpy version of cxg datatypes,
|
||||||
|
# then store the contents in the tiledb array A
|
||||||
A[:] = value
|
A[:] = value
|
||||||
A.meta["cxg_schema"] = json.dumps(schema_hints)
|
A.meta["cxg_schema"] = json.dumps(schema_hints)
|
||||||
|
|
||||||
@@ -598,7 +604,7 @@ def create_cxg_group_metadata(adata, basefname, title=None, about=None, corpora_
|
|||||||
return cxg_group_metadata
|
return cxg_group_metadata
|
||||||
|
|
||||||
|
|
||||||
def sanitize_keys(keys):
|
def sanitize_keys(keys, update_keys=True):
|
||||||
"""
|
"""
|
||||||
We need names to be safe to use as attribute names in tiledb. See:
|
We need names to be safe to use as attribute names in tiledb. See:
|
||||||
TileDB-Inc/TileDB#1575
|
TileDB-Inc/TileDB#1575
|
||||||
@@ -635,6 +641,8 @@ def sanitize_keys(keys):
|
|||||||
|
|
||||||
for k, v, in clean_unique_keys.items():
|
for k, v, in clean_unique_keys.items():
|
||||||
if k != v:
|
if k != v:
|
||||||
|
if update_keys is False:
|
||||||
|
raise AnnotationCategoryNameError(f"{k} not a valid category name, please resubmit")
|
||||||
log(1, f"Renaming {k} to {v}")
|
log(1, f"Renaming {k} to {v}")
|
||||||
return clean_unique_keys
|
return clean_unique_keys
|
||||||
|
|
||||||
|
|||||||
+29
-2
@@ -1,9 +1,10 @@
|
|||||||
import typing
|
import typing
|
||||||
|
import uuid
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
from server.db.cellxgene_orm import Base
|
from server.db.cellxgene_orm import Base, CellxGeneDataset, CellxGeneUser
|
||||||
|
|
||||||
|
|
||||||
class DbUtils:
|
class DbUtils:
|
||||||
@@ -34,7 +35,33 @@ class DbUtils:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def query_for_most_recent(self, table: Base, filter_args: typing.List[bool] = None) -> Base:
|
def query_for_most_recent(self, table: Base, filter_args: typing.List[bool] = None) -> Base:
|
||||||
return self.session.query(table).filter(*filter_args).order_by(table.created_at.desc()).limit(1).all()[0]
|
try:
|
||||||
|
return self.session.query(table).filter(*filter_args).order_by(table.created_at.desc()).limit(1).all()[0]
|
||||||
|
except IndexError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_or_create_dataset(self, dataset_name):
|
||||||
|
try:
|
||||||
|
dataset_id = self.query(
|
||||||
|
table_args=[CellxGeneDataset], filter_args=[CellxGeneDataset.name == dataset_name]
|
||||||
|
)[0].id
|
||||||
|
except IndexError:
|
||||||
|
dataset_id = uuid.uuid4()
|
||||||
|
dataset = CellxGeneDataset(id=dataset_id, name=dataset_name)
|
||||||
|
self.session.add(dataset)
|
||||||
|
self.session.commit()
|
||||||
|
return str(dataset_id)
|
||||||
|
|
||||||
|
def get_or_create_user(self, user_id):
|
||||||
|
try:
|
||||||
|
user_id = self.query(
|
||||||
|
table_args=[CellxGeneUser], filter_args=[CellxGeneUser.id == user_id]
|
||||||
|
)[0].id
|
||||||
|
except IndexError:
|
||||||
|
user = CellxGeneUser(id=user_id)
|
||||||
|
self.session.add(user)
|
||||||
|
self.session.commit()
|
||||||
|
return str(user_id)
|
||||||
|
|
||||||
|
|
||||||
class DBSessionMaker:
|
class DBSessionMaker:
|
||||||
|
|||||||
+2
-1
@@ -73,7 +73,8 @@ def handle_config_from_secret(app_config):
|
|||||||
|
|
||||||
keyattrs = (
|
keyattrs = (
|
||||||
("flask_secret_key", "app__flask_secret_key"),
|
("flask_secret_key", "app__flask_secret_key"),
|
||||||
("oauth_client_secret", "authentication__params_oauth__client_secret")
|
("oauth_client_secret", "authentication__params_oauth__client_secret"),
|
||||||
|
("db_uri", "user_annotations__hosted_tiledb_array__db_uri"),
|
||||||
)
|
)
|
||||||
|
|
||||||
for key, attr in keyattrs:
|
for key, attr in keyattrs:
|
||||||
|
|||||||
+35
-2
@@ -11,18 +11,50 @@ from contextlib import contextmanager
|
|||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
from server.common.annotations import AnnotationsLocalFile
|
from server.common.annotations.hosted_tiledb import AnnotationsHostedTileDB
|
||||||
|
from server.common.annotations.local_file_csv import AnnotationsLocalFile
|
||||||
|
|
||||||
from server.common.data_locator import DataLocator
|
from server.common.data_locator import DataLocator
|
||||||
from server.common.app_config import AppConfig, DEFAULT_SERVER_PORT
|
from server.common.app_config import AppConfig, DEFAULT_SERVER_PORT
|
||||||
from server.common.utils import find_available_port
|
from server.common.utils import find_available_port
|
||||||
from server.data_common.fbs.matrix import encode_matrix_fbs
|
from server.data_common.fbs.matrix import encode_matrix_fbs
|
||||||
from server.data_common.matrix_loader import MatrixDataLoader, MatrixDataType
|
from server.data_common.matrix_loader import MatrixDataLoader, MatrixDataType
|
||||||
|
from server.db.db_utils import DbUtils
|
||||||
|
|
||||||
PROJECT_ROOT = popen("git rev-parse --show-toplevel").read().strip()
|
PROJECT_ROOT = popen("git rev-parse --show-toplevel").read().strip()
|
||||||
FIXTURES_ROOT = PROJECT_ROOT + "/server/test/fixtures"
|
FIXTURES_ROOT = PROJECT_ROOT + "/server/test/fixtures"
|
||||||
|
|
||||||
|
|
||||||
|
def data_with_tmp_tiledb_annotations(ext: MatrixDataType):
|
||||||
|
tmp_dir = tempfile.mkdtemp()
|
||||||
|
fname = {
|
||||||
|
MatrixDataType.H5AD: f"{PROJECT_ROOT}/example-dataset/pbmc3k.h5ad",
|
||||||
|
MatrixDataType.CXG: "test/fixtures/pbmc3k.cxg",
|
||||||
|
}[ext]
|
||||||
|
data_locator = DataLocator(fname)
|
||||||
|
config = AppConfig()
|
||||||
|
config.update_server_config(
|
||||||
|
multi_dataset__dataroot=data_locator.path, authentication__type="test"
|
||||||
|
)
|
||||||
|
config.update_default_dataset_config(
|
||||||
|
embeddings__names=["umap"],
|
||||||
|
presentation__max_categories=100,
|
||||||
|
diffexp__lfc_cutoff=0.01,
|
||||||
|
user_annotations__type="hosted_tiledb_array",
|
||||||
|
user_annotations__hosted_tiledb_array__db_uri="postgresql://postgres:test_pw@localhost:5432",
|
||||||
|
user_annotations__hosted_tiledb_array__hosted_file_directory=tmp_dir
|
||||||
|
)
|
||||||
|
|
||||||
|
config.complete_config()
|
||||||
|
|
||||||
|
data = MatrixDataLoader(data_locator.abspath()).open(config)
|
||||||
|
annotations = AnnotationsHostedTileDB(
|
||||||
|
tmp_dir,
|
||||||
|
DbUtils("postgresql://postgres:test_pw@localhost:5432")
|
||||||
|
)
|
||||||
|
return data, tmp_dir, annotations
|
||||||
|
|
||||||
|
|
||||||
def data_with_tmp_annotations(ext: MatrixDataType, annotations_fixture=False):
|
def data_with_tmp_annotations(ext: MatrixDataType, annotations_fixture=False):
|
||||||
tmp_dir = tempfile.mkdtemp()
|
tmp_dir = tempfile.mkdtemp()
|
||||||
annotations_file = path.join(tmp_dir, "test_annotations.csv")
|
annotations_file = path.join(tmp_dir, "test_annotations.csv")
|
||||||
@@ -40,6 +72,7 @@ def data_with_tmp_annotations(ext: MatrixDataType, annotations_fixture=False):
|
|||||||
config.update_default_dataset_config(
|
config.update_default_dataset_config(
|
||||||
embeddings__names=["umap"], presentation__max_categories=100, diffexp__lfc_cutoff=0.01,
|
embeddings__names=["umap"], presentation__max_categories=100, diffexp__lfc_cutoff=0.01,
|
||||||
)
|
)
|
||||||
|
|
||||||
config.complete_config()
|
config.complete_config()
|
||||||
data = MatrixDataLoader(data_locator.abspath()).open(config)
|
data = MatrixDataLoader(data_locator.abspath()).open(config)
|
||||||
annotations = AnnotationsLocalFile(None, annotations_file)
|
annotations = AnnotationsLocalFile(None, annotations_file)
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import json
|
import json
|
||||||
from os import path, listdir
|
from os import path, listdir
|
||||||
import unittest
|
import unittest
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import tiledb
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
import server.test.unit.decode_fbs as decode_fbs
|
import server.test.unit.decode_fbs as decode_fbs
|
||||||
import shutil
|
import shutil
|
||||||
|
|
||||||
@@ -8,8 +13,134 @@ import numpy as np
|
|||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
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.db.cellxgene_orm import CellxGeneDataset, Annotation
|
||||||
|
from server.test import data_with_tmp_annotations, make_fbs, data_with_tmp_tiledb_annotations
|
||||||
from server.data_common.matrix_loader import MatrixDataType
|
from server.data_common.matrix_loader import MatrixDataType
|
||||||
|
from server.common.errors import AnnotationCategoryNameError
|
||||||
|
|
||||||
|
|
||||||
|
class auth(object):
|
||||||
|
def get_user_id():
|
||||||
|
return "1234"
|
||||||
|
|
||||||
|
|
||||||
|
class WritableTileDBStoredAnnotationTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user_id = '1234'
|
||||||
|
self.data, self.tmp_dir, self.annotations = data_with_tmp_tiledb_annotations(MatrixDataType.H5AD)
|
||||||
|
self.data.dataset_config.user_annotations = self.annotations
|
||||||
|
self.db = self.annotations.db
|
||||||
|
self.n_rows = self.data.get_shape()[0]
|
||||||
|
self.test_dict = {
|
||||||
|
"cat_A": pd.Series(["label_A"] * self.n_rows, dtype="category"),
|
||||||
|
"cat_B": pd.Series(["label_B"] * self.n_rows, dtype="category"),
|
||||||
|
}
|
||||||
|
self.fbs = make_fbs(self.test_dict)
|
||||||
|
self.df = pd.DataFrame(self.test_dict)
|
||||||
|
self.app = Flask('fake_app')
|
||||||
|
self.app.__setattr__("auth", auth)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
shutil.rmtree(self.tmp_dir)
|
||||||
|
|
||||||
|
def annotation_put_fbs(self, fbs):
|
||||||
|
annotations_put_fbs_helper(self.data, fbs)
|
||||||
|
res = json.dumps({"status": "OK"})
|
||||||
|
return res
|
||||||
|
|
||||||
|
def test_category_name_throws_errors_for_categories_that_cant_be_converted_to_filenames(self):
|
||||||
|
with self.app.test_request_context():
|
||||||
|
bad_category_names = make_fbs(
|
||||||
|
{
|
||||||
|
"cat_A": pd.Series(["label_A"] * self.n_rows, dtype="category"),
|
||||||
|
"cat/B": pd.Series(["label_B"] * self.n_rows, dtype="category"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
with self.assertRaises(AnnotationCategoryNameError):
|
||||||
|
self.annotation_put_fbs(bad_category_names)
|
||||||
|
|
||||||
|
def test_convert_to_pandas__converts_tiledb_to_pandas_df(self):
|
||||||
|
with self.app.test_request_context():
|
||||||
|
self.annotations.write_labels(self.df, self.data)
|
||||||
|
dataset_id = self.db.query([CellxGeneDataset], [CellxGeneDataset.name == self.data.get_location()])[0].id
|
||||||
|
annotation = self.db.query_for_most_recent(
|
||||||
|
Annotation,
|
||||||
|
[Annotation.user_id == self.user_id, Annotation.dataset_id == str(dataset_id)]
|
||||||
|
)
|
||||||
|
# retrieve tiledb array
|
||||||
|
df = tiledb.open(annotation.tiledb_uri)
|
||||||
|
self.assertEqual(type(df), tiledb.array.SparseArray)
|
||||||
|
|
||||||
|
# convert to pandas df
|
||||||
|
pandas_df = self.annotations.convert_to_pandas_df(df)
|
||||||
|
self.assertEqual(type(pandas_df), pd.DataFrame)
|
||||||
|
|
||||||
|
def test_write_labels_creates_a_dataset_if_it_doesnt_exist(self):
|
||||||
|
with self.app.test_request_context():
|
||||||
|
|
||||||
|
new_name = 'new_dataset/location'
|
||||||
|
self.data.get_location = MagicMock(return_value=new_name)
|
||||||
|
num_datasets = len(self.db.query([CellxGeneDataset]))
|
||||||
|
self.annotation_put_fbs(self.fbs)
|
||||||
|
more_datasets = len(self.db.query([CellxGeneDataset]))
|
||||||
|
self.assertGreater(more_datasets, num_datasets)
|
||||||
|
|
||||||
|
self.assertGreater(len(self.db.query([CellxGeneDataset], [CellxGeneDataset.name == new_name])), 0)
|
||||||
|
|
||||||
|
def test_write_labels_links_to_existing_dataset(self):
|
||||||
|
with self.app.test_request_context():
|
||||||
|
# add dataset to to db
|
||||||
|
self.annotation_put_fbs(self.fbs)
|
||||||
|
|
||||||
|
num_datasets = len(self.db.query([CellxGeneDataset]))
|
||||||
|
|
||||||
|
# create another annotation with the same dataset
|
||||||
|
self.annotation_put_fbs(self.fbs)
|
||||||
|
|
||||||
|
same_num_datasets = len(self.db.query([CellxGeneDataset]))
|
||||||
|
|
||||||
|
self.assertEqual(num_datasets, same_num_datasets)
|
||||||
|
|
||||||
|
def test_read_labels_returns_pandas_df(self):
|
||||||
|
with self.app.test_request_context():
|
||||||
|
self.annotation_put_fbs(self.fbs)
|
||||||
|
pandas_df = self.annotations.read_labels(self.data)
|
||||||
|
self.assertEqual(type(pandas_df), pd.DataFrame)
|
||||||
|
|
||||||
|
def test_read_labels_returns_df_matching_original(self):
|
||||||
|
with self.app.test_request_context():
|
||||||
|
self.annotation_put_fbs(self.fbs)
|
||||||
|
pandas_df = self.annotations.read_labels(self.data)
|
||||||
|
|
||||||
|
self.assertEqual(pandas_df.shape, (self.n_rows, 2))
|
||||||
|
self.assertEqual(set(pandas_df.columns), {"cat_A", "cat_B"})
|
||||||
|
self.assertTrue(self.data.original_obs_index.equals(pandas_df.index))
|
||||||
|
self.assertTrue(np.all(pandas_df["cat_A"] == ["label_A"] * self.n_rows))
|
||||||
|
self.assertTrue(np.all(pandas_df["cat_B"] == ["label_B"] * self.n_rows))
|
||||||
|
|
||||||
|
def test_error_checks(self):
|
||||||
|
# verify that the expected errors are generated
|
||||||
|
with self.app.test_request_context():
|
||||||
|
n_rows = self.data.get_shape()[0]
|
||||||
|
fbs_bad = make_fbs({"louvain": pd.Series(["undefined"] * n_rows, dtype="category")})
|
||||||
|
|
||||||
|
# ensure we catch attempt to overwrite non-writable data
|
||||||
|
with self.assertRaises(KeyError):
|
||||||
|
self.annotation_put_fbs(fbs_bad)
|
||||||
|
|
||||||
|
@patch('server.common.annotations.hosted_tiledb.current_app')
|
||||||
|
def test_write_labels_stores_df_as_tiledb_array(self, mock_user_id):
|
||||||
|
mock_user_id.auth.get_user_id.return_value = '1234'
|
||||||
|
self.annotations.write_labels(self.df, self.data)
|
||||||
|
# get uri
|
||||||
|
dataset_id = self.db.query([CellxGeneDataset], [CellxGeneDataset.name == self.data.get_location()])[0].id
|
||||||
|
annotation = self.db.query_for_most_recent(
|
||||||
|
Annotation,
|
||||||
|
[Annotation.user_id == '1234', Annotation.dataset_id == str(dataset_id)]
|
||||||
|
)
|
||||||
|
|
||||||
|
df = tiledb.open(annotation.tiledb_uri)
|
||||||
|
self.assertEqual(type(df), tiledb.array.SparseArray)
|
||||||
|
|
||||||
|
|
||||||
class WritableAnnotationTest(unittest.TestCase):
|
class WritableAnnotationTest(unittest.TestCase):
|
||||||
|
|||||||
Reference in New Issue
Block a user