move common code into server, update tests and makefile (#2425)

* move common code into server, update tests and makefile

remove backend directory, refactor

update smoke tests
This commit is contained in:
Madison Dunitz
2021-09-20 18:50:06 -07:00
committed by GitHub
parent 97caa5bcaa
commit 3ebbb0ccbf
217 changed files with 277 additions and 292 deletions
View File
+22
View File
@@ -0,0 +1,22 @@
class CorporaConstants(object):
REQUIRED_SIMPLE_METADATA_FIELDS = [
"version",
"title",
"layer_descriptions",
"organism",
"organism_ontology_term_id",
]
# The Corpora specification requires some values encoded as JSON due to the inability of AnnData to store complex
# types.
OPTIONAL_JSON_ENCODED_METADATA_FIELD = ["contributors", "project_links"]
OPTIONAL_SIMPLE_METADATA_FIELDS = [
"preprint_doi",
"publication_doi",
"default_embedding",
"default_field",
"tags",
"project_name",
"project_description",
]
+154
View File
@@ -0,0 +1,154 @@
import os
import tempfile
import fsspec
from datetime import datetime
import boto3
import botocore
from urllib.parse import urlparse
class DataLocator:
"""
DataLocator is a simple wrapper around fsspec functionality, and provides a
set of functions to encapsulate a data location (URI or path), interogate
metadata about the object at that location (size, existance, etc) and
access the underlying data.
https://filesystem-spec.readthedocs.io/en/latest/index.html
Example:
dl = DataLocator("/tmp/foo.h5ad")
if dl.exists():
print(dl.size())
with dl.open() as f:
thecontents = f.read()
DataLocator will accept a URI or native path. Error handling is as defined
in fsspec.
"""
def __init__(self, uri_or_path, region_name=None):
if isinstance(uri_or_path, DataLocator):
locator = uri_or_path
self.uri_or_path = locator.uri_or_path
self.protocol = locator.protocol
self.path = locator.path
self.cname = locator.cname
else:
self.uri_or_path = uri_or_path
self.protocol, self.path = DataLocator._get_protocol_and_path(uri_or_path)
# work-around for LocalFileSystem not treating file: and None as the same scheme/protocol
self.cname = self.path if self.protocol == "file" else self.uri_or_path
# fsspec.filesystem will throw RuntimeError if the protocol is unsupported
if self.protocol == "s3":
if region_name:
config_kwargs = dict(region_name=region_name)
self.fs = fsspec.filesystem(self.protocol, listings_expiry_time=30, config_kwargs=config_kwargs)
else:
self.fs = fsspec.filesystem(self.protocol, listings_expiry_time=30)
else:
self.fs = fsspec.filesystem(self.protocol)
def __repr__(self):
return f"DataLocator(protocol={self.protocol}, cname={self.cname}, "
f"path={self.path}, uri_or_path={self.uri_or_path})"
@staticmethod
def _get_protocol_and_path(uri_or_path):
if "://" in uri_or_path:
protocol, path = uri_or_path.split("://", 1)
# windows!!! Ignore single letter drive identifiers,
# eg, G:\foo.txt
if len(protocol) > 1:
return protocol, path
return None, uri_or_path
def exists(self):
return self.fs.exists(self.cname)
def size(self):
return self.fs.size(self.cname)
def lastmodtime(self):
""" return datetime object representing last modification time, or None if unavailable """
info = self.fs.info(self.cname)
if self.islocal() and info is not None:
return datetime.fromtimestamp(info["mtime"])
else:
return getattr(info, "LastModified", None)
def abspath(self):
"""
return the absolute path for the locator - only really does something
for file: protocol, as all others are already absolute
"""
if self.islocal():
return os.path.abspath(self.path)
else:
return self.uri_or_path
def isfile(self):
return self.fs.isfile(self.cname)
def open(self, *args):
return self.fs.open(self.uri_or_path, *args)
def islocal(self):
return self.protocol is None or self.protocol == "file"
def local_handle(self):
if self.islocal():
return LocalFilePath(self.path)
# if not local, create a tmp file system object to contain the data,
# and clean it up when done. If the path has a suffix/extension,
# do our best to create a file with the same.
ext = os.path.splitext(self.path)
suffix = None if ext[1] == "" else ext[1]
with self.open() as src, tempfile.NamedTemporaryFile(prefix="cellxgene_", suffix=suffix, delete=False) as tmp:
tmp.write(src.read())
tmp.close()
src.close()
tmp_path = tmp.name
return LocalFilePath(tmp_path, delete=True)
def ls(self):
paths = self.fs.ls(self.uri_or_path)
return [os.path.basename(p) for p in paths]
class LocalFilePath:
def __init__(self, tmp_path, delete=False):
self.tmp_path = tmp_path
self.delete = delete
def __enter__(self):
return self.tmp_path
def __exit__(self, *args):
if self.delete:
os.unlink(self.tmp_path)
def discover_s3_region_name(uri):
"""If this is an s3 protocol, discover and return the (aws) region name.
If a return name could not be discovered, or if the uri is not an s3 protocol, return None."""
protocol, _ = DataLocator._get_protocol_and_path(uri)
if protocol == "s3":
bucket = urlparse(uri).netloc
client = boto3.client("s3")
try:
res = client.head_bucket(Bucket=bucket)
except botocore.exceptions.ClientError:
return None
region = res.get("ResponseMetadata", {}).get("HTTPHeaders", {}).get("x-amz-bucket-region")
if region:
return region
else:
return None
return None
@@ -0,0 +1,191 @@
from typing import Union, Tuple
import logging
import numpy as np
import pandas as pd
"""
These routines drive all type inference for the schema generation and the
FBS (REST OTA) encoding.
H5AD Type REST REST
(ndarray, Series, Index) FBS encoding schema type ERROR/exceptions
---------------------------- -------------- --------------- ----------------------
bool_/bool uint8 boolean
(u)int8, (u)int16, int32 int32 int32
uint32, (u)int64 int32 int32 CHECKS value bounds
float16, float32, float64 float32 float32[0]
categorical[T is numeric[4]]:
hasna = False T categorical[1]
hasna = True float32 categorical[1] CHECKS value bounds
categorical[T not numeric] JSON/str categorical[1,2]
(other object) JSON/str string
(all other) Always an ERROR[3]
Notes:
[0] IEEE format, includes non-finite numbers (NaN, Inf, ...)
[1] with NO categories enumerated (client side does it to handle rounding)
[2] NA (undefined) categories are assigned a JSON null value
[3] Includes all other numpy types: datetime, complex, etc.
[4] means float, int, uint (dtype.kind in ['i','u','f'])
"""
def get_dtypes_and_schemas_of_dataframe(dataframe: pd.DataFrame):
dtypes_by_column_name = {}
schema_type_hints_by_column_name = {}
for column_name, column_values in dataframe.items():
(
dtypes_by_column_name[column_name],
schema_type_hints_by_column_name[column_name],
) = get_dtype_and_schema_of_array(column_values)
return dtypes_by_column_name, schema_type_hints_by_column_name
def get_encoding_dtype_of_array(array: Union[np.ndarray, pd.Series, pd.Index]) -> np.dtype:
return _get_type_info(array)[0]
def get_schema_type_hint_of_array(array: Union[np.ndarray, pd.Series, pd.Index]) -> dict:
return _get_type_info(array)[1]
def get_dtype_and_schema_of_array(array: Union[np.ndarray, pd.Series, pd.Index]) -> Tuple[np.dtype, dict]:
"""Return tuple (encoding_dtype, schema_type_hint)"""
return _get_type_info(array)
def get_schema_type_hint_from_dtype(dtype) -> dict:
res = _get_type_info_from_dtype(dtype)
if res is None:
raise TypeError(f"Annotations of type {dtype} are unsupported.")
else:
return res[1]
def _get_type_info_from_dtype(dtype) -> Union[Tuple[np.dtype, dict], None]:
"""
Best-effort to determine encoding type and schema hint from a dtype.
If this is not possible, or the type is unsupported, return None.
This should be a subset of the cases which are supported by
_get_type_info(). The latter should be preferred if the array (values)
are available for typing.
"""
if dtype.kind == "b":
return (np.uint8, {"type": "boolean"})
if dtype.kind == "U":
return (np.dtype(str), {"type": "string"})
if dtype.kind in ["i", "u"]:
if np.can_cast(dtype, np.int32):
return (np.int32, {"type": "int32"})
if dtype.kind == "f":
_float64_warning(dtype)
return (np.float32, {"type": "float32"})
if dtype.kind == "O" and not dtype.name == "category":
return (np.dtype(str), {"type": "string"})
return None
def _get_type_info(array: Union[np.ndarray, pd.Series, pd.Index]) -> Tuple[np.dtype, dict]:
"""
Determine encoding type and schema hint from an array. This allows more
flexible casting than may be possible by using just the dtype, as it can
account for category types and array values.
"""
if (
not isinstance(array, np.ndarray)
and not isinstance(array, pd.Series)
and not isinstance(array, pd.Index)
and not hasattr(array, "dtype")
):
raise TypeError("Unsupported data type.")
dtype = array.dtype
res = _get_type_info_from_dtype(dtype)
if res is not None:
return res
if dtype.kind == "O":
if dtype.name == "category":
# Sometimes CategoricalDType can be encoded as int or float without further fuss.
# Do not specify the categories in the schema - let the client-side figure it out
# on its own. Utilize Series.to_numpy() to do casting that handles categorical
# NA/NaN (missing or undefined) categories.
if dtype.categories.dtype.kind in ["f", "i", "u"]:
return (
_get_type_info(array.to_numpy())[0],
{"type": "categorical"},
)
else:
return (np.dtype(str), {"type": "categorical", "categories": dtype.categories.to_list()})
# all other extension types are str-encoded
return (np.dtype(str), {"type": "string"})
if dtype.kind in ["i", "u"] and _can_cast_array_values_to_int32(array):
return (np.int32, {"type": "int32"})
if dtype.kind == "f":
_float64_warning(array.dtype)
return (np.float32, {"type": "float32"})
raise TypeError(f"Annotations of type {dtype} are unsupported.")
def _float64_warning(dtype):
"""
Warn the user if we are down-casting a float64 to float32, and may potentially lose information.
"""
if dtype.kind == "f" and not np.can_cast(dtype, np.float32):
logging.warning(f"Type {dtype.name} will be converted to 32 bit float and may lose precision.")
def _can_cast_array_values_to_int32(array: Union[np.ndarray, pd.Series, pd.Index]) -> bool:
"""
Return true if the (U)INT array values can be safely cast to int32. We allow size reducing
casts (ie, int64 to int32) if no actual values require the larger size (ie, actual values
can be represented by the smaller type).
"""
assert array.dtype.kind in ["u", "i"]
if np.can_cast(array.dtype, np.int32):
return True
if array.size == 0:
return True
int32_machine_limits = np.iinfo(np.int32)
if array.min() >= int32_machine_limits.min and array.max() <= int32_machine_limits.max:
return True
return False
def convert_string_to_value(value: str):
"""convert a string to value with the most appropriate type"""
if value.lower() == "true":
return True
if value.lower() == "false":
return False
if value == "null":
return None
try:
return eval(value)
except: # noqa E722
return value
+126
View File
@@ -0,0 +1,126 @@
import contextlib
import errno
import importlib.util
import logging
import os
import pkgutil
import socket
from urllib.parse import urlsplit, urljoin
import numpy as np
from flask import json
from server.common.errors import ConfigurationError
def find_available_port(host, port=5005):
"""
Helper method to find open port on host. Tries 5000 ports incremented from the specified port
"""
# Takes approx 2 seconds to do a scan of 5000 ports on my laptop
num_ports_to_try = 5000
for port_to_try in range(port, port + num_ports_to_try):
if is_port_available(host, port_to_try):
return port_to_try
raise socket.error(errno.EADDRINUSE, f"No port in range {port} - {port + num_ports_to_try - 1} available.")
def is_port_available(host, port):
is_available = False
with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s:
try:
s.bind((host, port))
is_available = True
except socket.error:
pass
return is_available
def sort_options(command):
"""
Helper for the click options - will sort options in a command, and can
be used as a decorator.
"""
command.params.sort(key=lambda p: p.name)
return command
def path_join(base, *urls):
"""
this is like urllib.parse.urljoin, except it works around the scheme-specific
cleverness in the aforementioned code, ignores anything in the url except the path,
and accepts more than one url.
"""
if not base.endswith("/"):
base += "/"
btpl = urlsplit(base)
path = btpl.path
for url in urls:
utpl = urlsplit(url)
if btpl.scheme == "":
path = os.path.join(path, utpl.path)
path = os.path.normpath(path)
else:
path = urljoin(path, utpl.path)
return btpl._replace(path=path).geturl()
class StrictJSONEncoder(json.JSONEncoder):
"""
Custom JSON encoder set-up performing two tasks:
1. Strict JSON conformance with non-finite floats (NaN, +/-Inf) via allow_nan=False
2. Convert various Numpy types into python types so the encoder will correctly encode.
"""
def __init__(self, *args, **kwargs):
"""
NaN/Infinities are illegal in standard JSON. Python extends JSON with
non-standard symbols that most JavaScript JSON parsers do not understand.
The `allow_nan` parameter will force Python simplejson to throw an ValueError
if it runs into non-finite floating point values which are unsupported by
standard JSON.
"""
kwargs["allow_nan"] = False
super().__init__(*args, **kwargs)
def default(self, obj):
"""This helps us convert types not supported by the native JSON encoder into
standard python types, eg, np.int64."""
if isinstance(obj, np.floating):
return float(obj)
if isinstance(obj, np.integer):
return int(obj)
return json.JSONEncoder.default(self, obj)
def custom_format_warning(msg, *args, **kwargs):
return f"[cellxgene] Warning: {msg} \n"
def jsonify_strict(data):
return json.dumps(data, cls=StrictJSONEncoder, allow_nan=False)
def import_plugins(plugin_module):
"""
Load optional plugin modules from server.common.plugins
If you would like to customize cellxgene, you can add submodules to server.common.plugins before running the app.
This code will import each, loading the code in each. If no plugins are defined, initializing the app continues as
normal.
"""
loaded_modules = []
try:
pkg = importlib.import_module(plugin_module)
for loader, name, is_pkg in pkgutil.walk_packages(pkg.__path__):
full_name = f"{plugin_module}.{name}"
try:
module = importlib.import_module(full_name)
except Exception as e:
raise ConfigurationError(f"Unexpected error while importing plugin: {plugin_module}.{name}: {str(e)}")
loaded_modules.append(module)
except ModuleNotFoundError as e:
# This exception occurs when the plugin_module does not exist (not an error).
logging.debug(f"No plugins found in module: {plugin_module}: {str(e)}")
return loaded_modules