mirror of
https://github.com/chanzuckerberg/cellxgene.git
synced 2026-10-07 14:28:12 +08:00
add fsspec support to gene set and cell annotations (#2512)
This commit is contained in:
@@ -57,7 +57,7 @@ class Annotations(metaclass=ABCMeta):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def write_gene_sets(self, gs, data_adaptor):
|
def write_gene_sets(self, gs, tid, data_adaptor):
|
||||||
"""Write the gene sets (gs) to a persistent storage such that it can later be read"""
|
"""Write the gene sets (gs) to a persistent storage such that it can later be read"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from hashlib import blake2b
|
|||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
from flask import session
|
from flask import session
|
||||||
|
from fsspec import AbstractFileSystem
|
||||||
|
|
||||||
from server import __version__ as cellxgene_version
|
from server import __version__ as cellxgene_version
|
||||||
from server.app.session import get_user_id
|
from server.app.session import get_user_id
|
||||||
@@ -62,21 +63,27 @@ class AnnotationsLocalFile(Annotations):
|
|||||||
self.check_user_annotations_enabled() # raises
|
self.check_user_annotations_enabled() # raises
|
||||||
|
|
||||||
fname = self._get_celllabels_filename(data_adaptor)
|
fname = self._get_celllabels_filename(data_adaptor)
|
||||||
|
empty_labels = pd.DataFrame()
|
||||||
|
if fname is None:
|
||||||
|
return empty_labels
|
||||||
|
|
||||||
with self.label_lock:
|
with self.label_lock:
|
||||||
if fname is not None and os.path.exists(fname) and os.path.getsize(fname) > 0:
|
locator = DataLocator(fname)
|
||||||
# returned the cached labels if possible, otherwise read them from the file
|
if not locator.exists() or locator.size() == 0:
|
||||||
if fname == self.last_label_fname:
|
return empty_labels
|
||||||
return self.last_labels
|
|
||||||
else:
|
# return the cached labels if possible
|
||||||
labels = pd.read_csv(
|
if fname == self.last_label_fname:
|
||||||
fname, dtype="category", index_col=0, header=0, comment="#", keep_default_na=False
|
return self.last_labels
|
||||||
)
|
|
||||||
# update the cache
|
# otherwise, read labels from file
|
||||||
self.last_label_fname = fname
|
with locator.open() as f:
|
||||||
self.last_labels = labels
|
labels = pd.read_csv(f, dtype="category", index_col=0, header=0, comment="#", keep_default_na=False)
|
||||||
return labels
|
|
||||||
else:
|
# update the cache
|
||||||
return pd.DataFrame()
|
self.last_label_fname = fname
|
||||||
|
self.last_labels = labels
|
||||||
|
return labels
|
||||||
|
|
||||||
def write_labels(self, df, data_adaptor):
|
def write_labels(self, df, data_adaptor):
|
||||||
self.check_user_annotations_enabled() # raises
|
self.check_user_annotations_enabled() # raises
|
||||||
@@ -95,13 +102,12 @@ class AnnotationsLocalFile(Annotations):
|
|||||||
|
|
||||||
fname = self._get_celllabels_filename(data_adaptor)
|
fname = self._get_celllabels_filename(data_adaptor)
|
||||||
self._backup(fname)
|
self._backup(fname)
|
||||||
if not df.empty:
|
locator = DataLocator(fname)
|
||||||
with open(fname, "w", newline="") as f:
|
with locator.open("w") as f:
|
||||||
|
if not df.empty:
|
||||||
if header is not None:
|
if header is not None:
|
||||||
f.write(header)
|
f.write(header)
|
||||||
df.to_csv(f)
|
df.to_csv(f)
|
||||||
else:
|
|
||||||
open(fname, "w").close()
|
|
||||||
|
|
||||||
# update the cache
|
# update the cache
|
||||||
self.last_label_fname = fname
|
self.last_label_fname = fname
|
||||||
@@ -109,26 +115,32 @@ class AnnotationsLocalFile(Annotations):
|
|||||||
|
|
||||||
def read_gene_sets(self, data_adaptor, context=None):
|
def read_gene_sets(self, data_adaptor, context=None):
|
||||||
fname = self._get_genesets_filename(data_adaptor)
|
fname = self._get_genesets_filename(data_adaptor)
|
||||||
gene_sets = {}
|
empty_gene_sets = {}
|
||||||
tid = None
|
|
||||||
with self.gene_sets_lock:
|
with self.gene_sets_lock:
|
||||||
tid = self.last_geneset_tid # inside the critical section
|
tid = self.last_geneset_tid # inside the critical section
|
||||||
if fname is not None and os.path.exists(fname) and os.path.getsize(fname) > 0:
|
if fname is None:
|
||||||
# return the cached genesets if possible, otherwise read from file and validate them
|
return (empty_gene_sets, tid)
|
||||||
if fname == self.last_geneset_fname:
|
|
||||||
gene_sets = self.last_geneset
|
|
||||||
else:
|
|
||||||
# read
|
|
||||||
gene_sets = read_gene_sets_tidycsv(DataLocator(fname), context)
|
|
||||||
|
|
||||||
# validate
|
locator = DataLocator(fname)
|
||||||
gene_sets = data_adaptor.check_new_gene_sets(gene_sets, context)
|
if not locator.exists() or locator.size() == 0:
|
||||||
|
return (empty_gene_sets, tid)
|
||||||
|
|
||||||
# update cache
|
# return the cached genesets if possible, otherwise read from file and validate them
|
||||||
self.last_geneset_fname = fname
|
if fname == self.last_geneset_fname:
|
||||||
self.last_geneset = gene_sets
|
return (self.last_geneset, tid)
|
||||||
|
|
||||||
return (gene_sets, tid)
|
# read
|
||||||
|
gene_sets = read_gene_sets_tidycsv(locator, context)
|
||||||
|
|
||||||
|
# validate
|
||||||
|
gene_sets = data_adaptor.check_new_gene_sets(gene_sets, context)
|
||||||
|
|
||||||
|
# update cache
|
||||||
|
self.last_geneset_fname = fname
|
||||||
|
self.last_geneset = gene_sets
|
||||||
|
|
||||||
|
return (gene_sets, tid)
|
||||||
|
|
||||||
def write_gene_sets(self, gene_sets, tid, data_adaptor):
|
def write_gene_sets(self, gene_sets, tid, data_adaptor):
|
||||||
self.check_gene_sets_save_enabled() # raises
|
self.check_gene_sets_save_enabled() # raises
|
||||||
@@ -157,9 +169,9 @@ class AnnotationsLocalFile(Annotations):
|
|||||||
|
|
||||||
fname = self._get_genesets_filename(data_adaptor)
|
fname = self._get_genesets_filename(data_adaptor)
|
||||||
self._backup(fname)
|
self._backup(fname)
|
||||||
with open(fname, "w", newline="") as f:
|
locator = DataLocator(fname)
|
||||||
f.write(header)
|
with locator.open("w", newline="") as f:
|
||||||
f.write(self.gene_sets_to_csv(gene_sets))
|
f.write(header + self.gene_sets_to_csv(gene_sets))
|
||||||
|
|
||||||
# update the cache
|
# update the cache
|
||||||
self.last_geneset_fname = fname
|
self.last_geneset_fname = fname
|
||||||
@@ -181,7 +193,7 @@ class AnnotationsLocalFile(Annotations):
|
|||||||
|
|
||||||
output_file = self.label_output_file or self.gene_sets_output_file
|
output_file = self.label_output_file or self.gene_sets_output_file
|
||||||
if output_file:
|
if output_file:
|
||||||
return os.path.dirname(os.path.abspath(output_file))
|
return os.path.dirname(DataLocator(output_file).abspath())
|
||||||
|
|
||||||
return os.getcwd()
|
return os.getcwd()
|
||||||
|
|
||||||
@@ -220,34 +232,37 @@ class AnnotationsLocalFile(Annotations):
|
|||||||
1. fname -> backup_dir/fname-TIME
|
1. fname -> backup_dir/fname-TIME
|
||||||
2. delete excess files in backup_dir
|
2. delete excess files in backup_dir
|
||||||
"""
|
"""
|
||||||
root, ext = os.path.splitext(fname)
|
locator = DataLocator(fname)
|
||||||
backup_dir = f"{root}-backups"
|
fs: AbstractFileSystem = locator.fs # Handle to underlying fsspec file system
|
||||||
|
|
||||||
# Make sure there is work to do
|
# Make sure there is work to do
|
||||||
if not os.path.exists(fname):
|
if not locator.exists():
|
||||||
return
|
return
|
||||||
|
|
||||||
|
root, ext = os.path.splitext(locator.abspath())
|
||||||
|
backup_dir = f"{root}-backups"
|
||||||
|
|
||||||
# Ensure backup_dir exists
|
# Ensure backup_dir exists
|
||||||
if not os.path.exists(backup_dir):
|
fs.mkdirs(backup_dir, exist_ok=True)
|
||||||
os.mkdir(backup_dir)
|
|
||||||
|
|
||||||
# Save current file to backup_dir
|
# Save current file to backup_dir
|
||||||
fname_base = os.path.basename(fname)
|
fname_base = os.path.basename(fname)
|
||||||
fname_base_root, fname_base_ext = os.path.splitext(fname_base)
|
fname_base_root, fname_base_ext = os.path.splitext(fname_base)
|
||||||
# don't use ISO standard time format, as it contains characters illegal on some filesytems.
|
# don't use ISO standard time format, as it contains characters illegal on some filesystems.
|
||||||
nowish = datetime.now().strftime("%Y-%m-%dT%H-%M-%S")
|
nowish = datetime.now().strftime("%Y-%m-%dT%H-%M-%S")
|
||||||
backup_fname = os.path.join(backup_dir, f"{fname_base_root}-{nowish}{fname_base_ext}")
|
backup_fname = os.path.join(backup_dir, f"{fname_base_root}-{nowish}{fname_base_ext}")
|
||||||
if os.path.exists(backup_fname):
|
if fs.exists(backup_fname):
|
||||||
os.remove(backup_fname)
|
fs.delete(backup_fname)
|
||||||
os.rename(fname, backup_fname)
|
fs.rename(fname, backup_fname)
|
||||||
|
|
||||||
# prune the backup_dir to max number of backup files, keeping the most recent backups
|
# prune the backup_dir to max number of backup files, keeping the most recent backups
|
||||||
backups = list(filter(lambda s: s.startswith(fname_base_root), os.listdir(backup_dir)))
|
backup_path_prefix = DataLocator.strip_protocol(os.path.join(backup_dir, fname_base_root + "-"))
|
||||||
excess_count = len(backups) - max_backups
|
backups = list(filter(lambda s: s.startswith(backup_path_prefix), fs.ls(backup_dir)))
|
||||||
if excess_count > 0:
|
|
||||||
backups.sort()
|
# sorting to drop the oldest
|
||||||
for bu in backups[0:excess_count]:
|
excess_backups = list(sorted(backups, reverse=True))[max_backups:]
|
||||||
os.remove(os.path.join(backup_dir, bu))
|
for bu in excess_backups:
|
||||||
|
fs.delete(bu)
|
||||||
|
|
||||||
def update_parameters(self, parameters, data_adaptor):
|
def update_parameters(self, parameters, data_adaptor):
|
||||||
params = {}
|
params = {}
|
||||||
|
|||||||
@@ -52,8 +52,10 @@ class DataLocator:
|
|||||||
self.fs = fsspec.filesystem(self.protocol)
|
self.fs = fsspec.filesystem(self.protocol)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"DataLocator(protocol={self.protocol}, cname={self.cname}, "
|
return (
|
||||||
f"path={self.path}, uri_or_path={self.uri_or_path})"
|
f"DataLocator(protocol={self.protocol}, cname={self.cname}, "
|
||||||
|
f"path={self.path}, uri_or_path={self.uri_or_path})"
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_protocol_and_path(uri_or_path):
|
def _get_protocol_and_path(uri_or_path):
|
||||||
@@ -65,6 +67,10 @@ class DataLocator:
|
|||||||
return protocol, path
|
return protocol, path
|
||||||
return None, uri_or_path
|
return None, uri_or_path
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def strip_protocol(uri_or_path):
|
||||||
|
return DataLocator._get_protocol_and_path(uri_or_path)[1]
|
||||||
|
|
||||||
def exists(self):
|
def exists(self):
|
||||||
return self.fs.exists(self.cname)
|
return self.fs.exists(self.cname)
|
||||||
|
|
||||||
@@ -72,7 +78,7 @@ class DataLocator:
|
|||||||
return self.fs.size(self.cname)
|
return self.fs.size(self.cname)
|
||||||
|
|
||||||
def lastmodtime(self):
|
def lastmodtime(self):
|
||||||
""" return datetime object representing last modification time, or None if unavailable """
|
"""return datetime object representing last modification time, or None if unavailable"""
|
||||||
info = self.fs.info(self.cname)
|
info = self.fs.info(self.cname)
|
||||||
if self.islocal() and info is not None:
|
if self.islocal() and info is not None:
|
||||||
return datetime.fromtimestamp(info["mtime"])
|
return datetime.fromtimestamp(info["mtime"])
|
||||||
@@ -92,8 +98,8 @@ class DataLocator:
|
|||||||
def isfile(self):
|
def isfile(self):
|
||||||
return self.fs.isfile(self.cname)
|
return self.fs.isfile(self.cname)
|
||||||
|
|
||||||
def open(self, *args):
|
def open(self, *args, **kwargs):
|
||||||
return self.fs.open(self.uri_or_path, *args)
|
return self.fs.open(self.uri_or_path, *args, **kwargs)
|
||||||
|
|
||||||
def islocal(self):
|
def islocal(self):
|
||||||
return self.protocol is None or self.protocol == "file"
|
return self.protocol is None or self.protocol == "file"
|
||||||
|
|||||||
Reference in New Issue
Block a user