1 Commits

Author SHA1 Message Date
Alok Saldanha
a6c13a7606 Applied "Introduction of ItemSource interface" patch 2020-10-08 08:37:31 -04:00
35 changed files with 134 additions and 359 deletions

View File

@@ -1,13 +0,0 @@
[run]
branch = True
source = cellxgene_gateway
[report]
exclude_lines =
if self.debug:
pragma: no cover
raise NotImplementedError
if __name__ == .__main__.:
ignore_errors = True
omit =
tests/*

View File

@@ -1,67 +0,0 @@
# Tests that run on every PR
name: Pull Request Checks
on: [push, pull_request]
jobs:
black:
runs-on: ubuntu-18.04
steps:
- uses: actions/checkout@v2
name: Checkout repository
- uses: actions/setup-python@v2
name: Setup Python
with:
python-version: 3.9
- name: Install black
run: |
python -m pip install --upgrade pip
pip install black
- name: Run black
run: |
black . --check
# This job is copied over from `deploy.yaml`
run-tests:
runs-on: ubuntu-18.04
steps:
- uses: actions/checkout@v2
# See: https://github.com/marketplace/actions/setup-conda
- uses: s-weigand/setup-conda@v1
with:
conda-channels: "conda-forge"
- name: Build environment
run: |
conda env create -f environment.yml
eval "$(conda shell.bash hook)"
conda activate cellxgene-gateway
python setup.py install
- name: Run tests
run: |
eval "$(conda shell.bash hook)"
conda activate cellxgene-gateway
coverage run -m unittest discover tests
- name: Check coverage
run: |
eval "$(conda shell.bash hook)"
conda activate cellxgene-gateway
coverage report --fail-under 47
coverage xml -i
- name: "Upload coverage to Codecov"
uses: codecov/codecov-action@v1
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage.xml
flags: unittests
env_vars: OS,PYTHON
name: codecov-umbrella
fail_ci_if_error: true
path_to_write_report: ./codecov_report.txt
verbose: true

1
.gitignore vendored
View File

@@ -55,7 +55,6 @@ htmlcov/
.nox/ .nox/
.coverage .coverage
.coverage.* .coverage.*
htmlcov
.cache .cache
nosetests.xml nosetests.xml
coverage.xml coverage.xml

View File

@@ -7,6 +7,12 @@ repos:
language: system language: system
types: [python] types: [python]
stages: [commit] stages: [commit]
- id: flake8
name: flake8
language: system
entry: flake8
types: [python]
stages: [commit]
- id: black - id: black
language_version: python3.6+ language_version: python3.6+
name: black name: black

View File

@@ -1,7 +1,3 @@
# 0.2.3
* Added support for ProxyFix
# 0.2.2 # 0.2.2
* Fixed bug with annotations (missing annotation.js asset) * Fixed bug with annotations (missing annotation.js asset)

View File

@@ -2,8 +2,6 @@
Cellxgene Gateway allows you to use the Cellxgene Server provided by the Chan Zuckerberg Institute (https://github.com/chanzuckerberg/cellxgene) with multiple datasets. It displays an index of available h5ad (anndata) files. When a user clicks on a file name, it launches a Cellxgene Server instance that loads that particular data file and once it is available proxies requests to that server. Cellxgene Gateway allows you to use the Cellxgene Server provided by the Chan Zuckerberg Institute (https://github.com/chanzuckerberg/cellxgene) with multiple datasets. It displays an index of available h5ad (anndata) files. When a user clicks on a file name, it launches a Cellxgene Server instance that loads that particular data file and once it is available proxies requests to that server.
[![codecov](https://codecov.io/gh/Novartis/cellxgene-gateway/branch/master/graph/badge.svg?token=ndEFSzRKJn)](https://codecov.io/gh/Novartis/cellxgene-gateway)
# Running locally # Running locally
## Prequisites ## Prequisites
@@ -75,14 +73,7 @@ Optional environment variables:
* `GATEWAY_PORT` - local port that the gateway should bind to, defaults to 5005 * `GATEWAY_PORT` - local port that the gateway should bind to, defaults to 5005
* `GATEWAY_EXTRA_SCRIPTS` - JSON array of script paths, will be embedded into each page and forwarded with `--scripts` to cellxgene server * `GATEWAY_EXTRA_SCRIPTS` - JSON array of script paths, will be embedded into each page and forwarded with `--scripts` to cellxgene server
* `GATEWAY_ENABLE_ANNOTATIONS` - Set to `true` or to `1` to enable cellxgene annotations. * `GATEWAY_ENABLE_ANNOTATIONS` - Set to `true` or to `1` to enable cellxgene annotations.
* `GATEWAY_ENABLE_BACKED_MODE` - Set to `true` or to `1` to load AnnData in file-backed mode. This saves memory and speeds up launch time but may reduce overall performance. * `GATEWAY_ENABLE_BACKED_MODE` - Set to `true` or to `1` to load AnnData in file-backed mode. This saves memory and speeds up launch time but may reduce overall performance.
If any of the following optional variables are set, [ProxyFix](https://werkzeug.palletsprojects.com/en/1.0.x/middleware/proxy_fix/) will be used.
* `PROXY_FIX_FOR` - Number of upstream proxies setting X-Forwarded-For
* `PROXY_FIX_PROTO` - Number of upstream proxies setting X-Forwarded-Proto
* `PROXY_FIX_HOST` - Number of upstream proxies setting X-Forwarded-Host
* `PROXY_FIX_PORT` - Number of upstream proxies setting X-Forwarded-Port
* `PROXY_FIX_PREFIX` - Number of upstream proxies setting X-Forwarded-Prefix
The defaults should be fine if you set up a venv and cellxgene_data folder as above. The defaults should be fine if you set up a venv and cellxgene_data folder as above.
@@ -126,14 +117,6 @@ python setup.py develop
For convenience, the code repo includes a `run.sh.example` shell script to run the gateway. For convenience, the code repo includes a `run.sh.example` shell script to run the gateway.
4. Install pre-commit hooks
```bash
conda install -c conda-forge pre-commit
pre-commit install
```
## Running Tests ## Running Tests
[![Build Status](https://travis-ci.org/Novartis/cellxgene-gateway.svg?branch=master)](https://travis-ci.org/Novartis/cellxgene-gateway) [![Build Status](https://travis-ci.org/Novartis/cellxgene-gateway.svg?branch=master)](https://travis-ci.org/Novartis/cellxgene-gateway)
@@ -142,12 +125,6 @@ pre-commit install
python -m unittest discover tests python -m unittest discover tests
``` ```
## Code Coverage
```bash
coverage run -m unittest discover tests
coverage html
```
## Running Linters ## Running Linters
pip install isort flake8 black pip install isort flake8 black

2
cellxgene_gateway/__init__.py Normal file → Executable file
View File

@@ -7,4 +7,4 @@
# OR CONDITIONS OF ANY KIND, either express or implied. See the License for # OR CONDITIONS OF ANY KIND, either express or implied. See the License for
# the specific language governing permissions and limitations under the License. # the specific language governing permissions and limitations under the License.
__version__ = "0.2.3" __version__ = "0.2.2"

View File

@@ -9,7 +9,6 @@
import time import time
from threading import Thread from threading import Thread
from typing import List
from flask_api import status from flask_api import status
@@ -18,6 +17,7 @@ from cellxgene_gateway.cache_entry import CacheEntry, CacheEntryStatus
from cellxgene_gateway.cache_key import CacheKey from cellxgene_gateway.cache_key import CacheKey
from cellxgene_gateway.cellxgene_exception import CellxgeneException from cellxgene_gateway.cellxgene_exception import CellxgeneException
from cellxgene_gateway.subprocess_backend import SubprocessBackend from cellxgene_gateway.subprocess_backend import SubprocessBackend
from typing import List
process_backend = SubprocessBackend() process_backend = SubprocessBackend()

View File

@@ -8,14 +8,13 @@
# the specific language governing permissions and limitations under the License. # the specific language governing permissions and limitations under the License.
import datetime import datetime
import logging import logging
import re
import urllib.parse import urllib.parse
from enum import Enum from enum import Enum
import psutil import psutil
from flask import make_response, render_template, request from flask import make_response, render_template, request
from flask.wrappers import Response
from requests import get, post, put from requests import get, post, put
import re
from cellxgene_gateway import env from cellxgene_gateway import env
from cellxgene_gateway.cellxgene_exception import CellxgeneException from cellxgene_gateway.cellxgene_exception import CellxgeneException
@@ -111,7 +110,9 @@ class CacheEntry:
except psutil.NoSuchProcess: except psutil.NoSuchProcess:
pass pass
logging.getLogger("cellxgene_gateway").info(f"terminated {terminated}") logging.getLogger("cellxgene_gateway").info(
f"terminated {terminated}"
)
self.status = CacheEntryStatus.terminated self.status = CacheEntryStatus.terminated
def rewrite_text_content(self, cellxgene_content): def rewrite_text_content(self, cellxgene_content):
@@ -119,22 +120,30 @@ class CacheEntry:
gateway_content = ( gateway_content = (
re.sub( re.sub(
'(="|\()/static/', '(="|\()/static/',
f"\\1{self.key.gateway_basepath()}static/", f"\\1{self.gateway_basepath()}static/",
cellxgene_content, cellxgene_content,
) )
.replace("http://fonts.gstatic.com", "https://fonts.gstatic.com") .replace("http://fonts.gstatic.com", "https://fonts.gstatic.com")
.replace(self.cellxgene_basepath(), self.key.gateway_basepath()) .replace(self.cellxgene_basepath(), self.gateway_basepath())
) )
return gateway_content return gateway_content
def gateway_basepath(self):
source_path = (
f"/source/{urllib.parse.quote_plus(self.source_name)}"
if self.source_name
else ""
)
return f"{env.external_protocol}://{env.external_host}{source_path}/view/{self.key.descriptor}/"
def cellxgene_basepath(self): def cellxgene_basepath(self):
return f"http://127.0.0.1:{self.port}" return f"http://127.0.0.1:{self.port}"
def serve_content(self, path): def serve_content(self, path):
gateway_basepath = self.key.gateway_basepath() gateway_basepath = self.gateway_basepath()
subpath = path[len(self.key.descriptor) :] # noqa: E203 subpath = path[len(self.key.descriptor) :] # noqa: E203
if len(subpath) == 0: if len(subpath) == 0:
r = make_response(f"Redirect to {gateway_basepath}\n", 302) r = make_response(f"Redirect to {gateway_basepath}\n", 301)
r.headers["location"] = gateway_basepath + querystring() r.headers["location"] = gateway_basepath + querystring()
return r return r
elif self.status == CacheEntryStatus.loading: elif self.status == CacheEntryStatus.loading:
@@ -184,7 +193,9 @@ class CacheEntry:
data=request.data, data=request.data,
) )
else: else:
raise CellxgeneException(f"Unexpected method {request.method}", 400) raise CellxgeneException(
f"Unexpected method {request.method}", 400
)
content_type = cellxgene_response.headers["content-type"] content_type = cellxgene_response.headers["content-type"]
if "text" in content_type: if "text" in content_type:
gateway_content = self.rewrite_text_content( gateway_content = self.rewrite_text_content(

View File

@@ -15,7 +15,6 @@
# 3) somedir/dataset_annotations: an annotation directory. The corresponding h5ad must exist, but the directory may not. # 3) somedir/dataset_annotations: an annotation directory. The corresponding h5ad must exist, but the directory may not.
# in this case, descriptor == 'somedir/dataset_annotations', dataset == 'somedir/dataset.h5ad' # in this case, descriptor == 'somedir/dataset_annotations', dataset == 'somedir/dataset.h5ad'
from cellxgene_gateway import flask_util
from cellxgene_gateway.items.item import Item from cellxgene_gateway.items.item import Item
from cellxgene_gateway.items.item_source import ItemSource, LookupResult from cellxgene_gateway.items.item_source import ItemSource, LookupResult
@@ -39,16 +38,6 @@ class CacheKey:
else: else:
return self.source.get_local_path(self.annotation_item) return self.source.get_local_path(self.annotation_item)
def relaunch_url(self):
return flask_util.relaunch_url(self.descriptor, self.source_name)
def gateway_basepath(self):
return self.view_url + "/"
@property
def view_url(self):
return flask_util.view_url(self.descriptor, self.source_name)
@property @property
def source_name(self): def source_name(self):
return self.source.name return self.source.name

View File

@@ -62,8 +62,3 @@ def make_h5ad(el):
def make_annotations(el): def make_annotations(el):
return el[:-5] + annotations_suffix return el[:-5] + annotations_suffix
def ensure_dir_exists(file_path):
if not os.path.exists(file_path):
os.makedirs(file_path)

View File

@@ -12,7 +12,7 @@ import os
import socket import socket
cellxgene_location = os.environ.get("CELLXGENE_LOCATION") cellxgene_location = os.environ.get("CELLXGENE_LOCATION")
cellxgene_data = os.environ.get("CELLXGENE_DATA", "") cellxgene_data = os.environ.get("CELLXGENE_DATA")
cellxgene_args = os.environ.get("CELLXGENE_ARGS", None) cellxgene_args = os.environ.get("CELLXGENE_ARGS", None)
gateway_port = int(os.environ.get("GATEWAY_PORT", "5005")) gateway_port = int(os.environ.get("GATEWAY_PORT", "5005"))
external_host = os.environ.get( external_host = os.environ.get(
@@ -25,11 +25,15 @@ external_protocol = os.environ.get(
ip = os.environ.get("GATEWAY_IP") ip = os.environ.get("GATEWAY_IP")
extra_scripts = os.environ.get("GATEWAY_EXTRA_SCRIPTS") extra_scripts = os.environ.get("GATEWAY_EXTRA_SCRIPTS")
ttl = os.environ.get("GATEWAY_TTL") ttl = os.environ.get("GATEWAY_TTL")
enable_annotations = os.environ.get("GATEWAY_ENABLE_ANNOTATIONS", "").lower() in [ enable_annotations = os.environ.get(
"GATEWAY_ENABLE_ANNOTATIONS", ""
).lower() in [
"true", "true",
"1", "1",
] ]
enable_backed_mode = os.environ.get("GATEWAY_ENABLE_BACKED_MODE", "").lower() in [ enable_backed_mode = os.environ.get(
"GATEWAY_ENABLE_BACKED_MODE", ""
).lower() in [
"true", "true",
"1", "1",
] ]
@@ -38,12 +42,6 @@ env_vars = {
"CELLXGENE_LOCATION": cellxgene_location, "CELLXGENE_LOCATION": cellxgene_location,
} }
proxy_fix_for = int(os.environ.get("PROXY_FIX_FOR", "0"))
proxy_fix_proto = int(os.environ.get("PROXY_FIX_PROTO", "0"))
proxy_fix_host = int(os.environ.get("PROXY_FIX_HOST", "0"))
proxy_fix_port = int(os.environ.get("PROXY_FIX_PORT", "0"))
proxy_fix_prefix = int(os.environ.get("PROXY_FIX_PREFIX", "0"))
optional_env_vars = { optional_env_vars = {
"EXTERNAL_HOST": external_host, "EXTERNAL_HOST": external_host,
"EXTERNAL_PROTOCOL": external_protocol, "EXTERNAL_PROTOCOL": external_protocol,
@@ -55,11 +53,6 @@ optional_env_vars = {
"GATEWAY_ENABLE_BACKED_MODE": enable_backed_mode, "GATEWAY_ENABLE_BACKED_MODE": enable_backed_mode,
"CELLXGENE_ARGS": cellxgene_args, "CELLXGENE_ARGS": cellxgene_args,
"CELLXGENE_DATA": cellxgene_data, "CELLXGENE_DATA": cellxgene_data,
"PROXY_FIX_FOR": proxy_fix_for,
"PROXY_FIX_PROTO": proxy_fix_proto,
"PROXY_FIX_HOST": proxy_fix_host,
"PROXY_FIX_PORT": proxy_fix_port,
"PROXY_FIX_PREFIX": proxy_fix_prefix,
} }

View File

@@ -10,20 +10,14 @@
import os import os
import urllib.parse import urllib.parse
from cellxgene_gateway import env, flask_util
from cellxgene_gateway.cache_key import CacheKey
from cellxgene_gateway.dir_util import annotations_suffix, make_annotations, make_h5ad
def render_annotations(item, item_source): def render_annotations(item, item_source):
url = flask_util.view_url( subpath = f"/source/{urllib.parse.quote_plus(item_source.name)}/view/"
item_source.get_annotations_subpath(item), item_source.name new_annotation = f"<a class='new' href='{subpath}{item_source.get_annotations_subpath(item)}'>new</a>"
)
new_annotation = f"<a class='new' href='{url}'>new</a>"
annotations = ( annotations = (
", ".join( ", ".join(
[ [
f"<a href='{CacheKey(item, item_source, a).view_url}/'>{a.name}</a>" f"<a href='{subpath}{a.descriptor}/'>{a.name}</a>"
for a in item.annotations for a in item.annotations
] ]
) )
@@ -31,11 +25,13 @@ def render_annotations(item, item_source):
if item.annotations if item.annotations
else "" else ""
) )
return " | annotations: " + annotations + new_annotation return " | annotations: " + annotations + new_annotation
def render_item(item, item_source): def render_item(item, item_source):
item_string = f"<li> <a href='{ CacheKey(item, item_source).view_url }/'>{item.name}</a> {render_annotations(item, item_source)}</li>" url = f"/source/{urllib.parse.quote_plus(item_source.name)}/view/{item.descriptor}/"
item_string = f"<li> <a href='{ url }'>{item.name}</a> {render_annotations(item, item_source)}</li>"
return item_string return item_string
@@ -46,7 +42,9 @@ def render_item_tree(item_tree, item_source):
else "" else ""
) )
branches = ( branches = (
"\n".join([render_item_tree(b, item_source) for b in item_tree.branches]) "\n".join(
[render_item_tree(b, item_source) for b in item_tree.branches]
)
if item_tree.branches if item_tree.branches
else "" else ""
) )
@@ -54,7 +52,11 @@ def render_item_tree(item_tree, item_source):
if item_tree.descriptor: if item_tree.descriptor:
descriptor = item_tree.descriptor.lstrip("/") descriptor = item_tree.descriptor.lstrip("/")
url = f"/filecrawl/{descriptor}?source={item_source.name}" url = f"/filecrawl/{descriptor}?source={item_source.name}"
name = descriptor.rsplit("/")[1] if descriptor.find("/") >= 0 else descriptor name = (
descriptor.rsplit("/")[1]
if descriptor.find("/") >= 0
else descriptor
)
return f"<li><a href='{url}'>{name}</a>{html}</li>" return f"<li><a href='{url}'>{name}</a>{html}</li>"
else: else:
return html return html

View File

@@ -7,27 +7,9 @@
# OR CONDITIONS OF ANY KIND, either express or implied. See the License for # OR CONDITIONS OF ANY KIND, either express or implied. See the License for
# the specific language governing permissions and limitations under the License. # the specific language governing permissions and limitations under the License.
from flask import request, url_for from flask import request
def querystring(): def querystring():
qs = request.query_string.decode() qs = request.query_string.decode()
return f"?{qs}" if len(qs) > 0 else "" return f"?{qs}" if len(qs) > 0 else ""
include_source_in_url = False
def url(endpoint, descriptor, source_name):
if include_source_in_url:
return url_for(endpoint, source_name=source_name, path=descriptor)
else:
return url_for(endpoint, path=descriptor)
def view_url(descriptor, source_name):
return url("do_view", descriptor, source_name)
def relaunch_url(descriptor, source_name):
return url("do_relaunch", descriptor, source_name)

View File

@@ -23,13 +23,11 @@ from flask import (
url_for, url_for,
) )
from flask_api import status from flask_api import status
from werkzeug.middleware.proxy_fix import ProxyFix
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from cellxgene_gateway import env, flask_util from cellxgene_gateway import env
from cellxgene_gateway.backend_cache import BackendCache from cellxgene_gateway.backend_cache import BackendCache
from cellxgene_gateway.cache_entry import CacheEntryStatus from cellxgene_gateway.cache_entry import CacheEntryStatus
from cellxgene_gateway.cache_key import CacheKey
from cellxgene_gateway.cellxgene_exception import CellxgeneException from cellxgene_gateway.cellxgene_exception import CellxgeneException
from cellxgene_gateway.dir_util import create_dir, is_subdir from cellxgene_gateway.dir_util import create_dir, is_subdir
from cellxgene_gateway.extra_scripts import get_extra_scripts from cellxgene_gateway.extra_scripts import get_extra_scripts
@@ -37,6 +35,7 @@ from cellxgene_gateway.filecrawl import render_item_source
from cellxgene_gateway.process_exception import ProcessException from cellxgene_gateway.process_exception import ProcessException
from cellxgene_gateway.prune_process_cache import PruneProcessCache from cellxgene_gateway.prune_process_cache import PruneProcessCache
from cellxgene_gateway.util import current_time_stamp from cellxgene_gateway.util import current_time_stamp
from cellxgene_gateway.cache_key import CacheKey
app = Flask(__name__) app = Flask(__name__)
@@ -53,23 +52,9 @@ def _force_https(app):
app.wsgi_app = _force_https(app.wsgi_app) app.wsgi_app = _force_https(app.wsgi_app)
if (
env.proxy_fix_for > 0
or env.proxy_fix_proto > 0
or env.proxy_fix_host > 0
or env.proxy_fix_port > 0
or env.proxy_fix_prefix > 0
):
app.wsgi_app = ProxyFix(
app.wsgi_app,
x_for=env.proxy_fix_for,
x_proto=env.proxy_fix_proto,
x_host=env.proxy_fix_host,
x_port=env.proxy_fix_port,
x_prefix=env.proxy_fix_prefix,
)
cache = BackendCache() cache = BackendCache()
location = f"{env.external_protocol}://{env.external_host}"
@app.errorhandler(CellxgeneException) @app.errorhandler(CellxgeneException)
@@ -105,7 +90,7 @@ def handle_invalid_process(error):
http_status=error.http_status, http_status=error.http_status,
stdout=error.stdout, stdout=error.stdout,
stderr=error.stderr, stderr=error.stderr,
relaunch_url=error.key.relaunch_url(), dataset=error.key.h5ad_item.descriptor,
annotation_file=error.key.annotation_descriptor, annotation_file=error.key.annotation_descriptor,
), ),
error.http_status, error.http_status,
@@ -240,14 +225,15 @@ def do_GET_status_json():
@app.route("/relaunch/<path:path>", methods=["GET"]) @app.route("/relaunch/<path:path>", methods=["GET"])
def do_relaunch(path): def do_relaunch(path):
source_name = request.args.get("source_name") or default_item_source.name source_name = request.args.get("source") or default_item_source.name
source = matching_source(source_name) source = matching_source(source_name)
key = CacheKey.for_lookup(source, source.lookup(path)) key = CacheKey.for_lookup(source, source.lookup(path))
match = cache.check_entry(key) match = cache.check_entry(key)
if not match is None: if not match is None:
match.terminate() match.terminate()
qs = request.query_string.decode()
return redirect( return redirect(
key.view_url, url_for("do_view", path=path) + (f"?{qs}" if len(qs) > 0 else ""),
code=302, code=302,
) )
@@ -301,7 +287,6 @@ def main():
default_item_source = "local" default_item_source = "local"
if len(item_sources) == 0: if len(item_sources) == 0:
raise Exception("Please specify CELLXGENE_DATA or CELLXGENE_BUCKET") raise Exception("Please specify CELLXGENE_DATA or CELLXGENE_BUCKET")
flask_util.include_source_in_url = len(item_sources) > 1
launch() launch()

View File

@@ -18,11 +18,10 @@ class FileItem(Item):
The Item superclass expects a 'name' and 'type'. The Item superclass expects a 'name' and 'type'.
""" """
def __init__(self, subpath: str, ext: str = "", *args, **kwargs): def __init__(self, subpath: str, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.subpath = subpath self.subpath = subpath
self.ext = ext
@property @property
def descriptor(self) -> str: def descriptor(self) -> str:
return os.path.join(self.subpath, self.name + self.ext).strip("/") return os.path.join(self.subpath, self.name).strip("/")

View File

@@ -67,7 +67,9 @@ class FileItemSource(ItemSource):
base_path = os.path.join(self.base_path, subpath) base_path = os.path.join(self.base_path, subpath)
if not os.path.exists(base_path): if not os.path.exists(base_path):
raise Exception(f"Path for local files '{base_path}' does not exist.") raise Exception(
f"Path for local files '{base_path}' does not exist."
)
filepath_map = dict( filepath_map = dict(
(filepath, os.path.join(base_path, filepath)) (filepath, os.path.join(base_path, filepath))
@@ -93,12 +95,14 @@ class FileItemSource(ItemSource):
] ]
items = [ items = [
self.make_fileitem_from_path(filename, subpath) for filename in h5ad_paths self.make_fileitem_from_path(filename, subpath)
for filename in h5ad_paths
] ]
branches = None branches = None
if len(subdirs) > 0: if len(subdirs) > 0:
branches = [ branches = [
self.scan_directory(os.path.join(subpath, subdir)) for subdir in subdirs self.scan_directory(os.path.join(subpath, subdir))
for subdir in subdirs
] ]
return ItemTree(subpath, items, branches) return ItemTree(subpath, items, branches)
@@ -124,13 +128,14 @@ class FileItemSource(ItemSource):
def lookup(self, indescriptor: str) -> LookupResult: def lookup(self, indescriptor: str) -> LookupResult:
descriptor = indescriptor.strip("/") descriptor = indescriptor.strip("/")
if descriptor.endswith(self.annotation_file_suffix): if descriptor.endswith(self.annotation_file_suffix):
annotation_item = self.shallowitem_from_descriptor(descriptor, True) annotation_item = self.shallowitem_from_descriptor(
descriptor, True
)
h5ad_descriptor = self.convert_annotation_path_to_h5ad( h5ad_descriptor = self.convert_annotation_path_to_h5ad(
annotation_item.subpath annotation_item.subpath
) )
item = self.lookup_item(h5ad_descriptor) item = self.lookup_item(h5ad_descriptor)
if item is not None: if item is not None:
dir_util.ensure_dir_exists(self.full_path(annotation_item.subpath))
return LookupResult(item, annotation_item) return LookupResult(item, annotation_item)
else: else:
item = self.lookup_item(descriptor) item = self.lookup_item(descriptor)
@@ -150,16 +155,9 @@ class FileItemSource(ItemSource):
def make_fileitem_from_path( def make_fileitem_from_path(
self, filename, subpath, is_annotation=False, is_shallow=False self, filename, subpath, is_annotation=False, is_shallow=False
) -> FileItem: ) -> FileItem:
if is_annotation and filename.endswith(self.annotation_file_suffix):
name = filename[: -len(self.annotation_file_suffix)]
ext = self.annotation_file_suffix
else:
name = filename
ext = ""
item = FileItem( item = FileItem(
subpath=subpath, subpath=subpath,
name=name, name=filename,
ext=ext,
type=ItemType.annotation if is_annotation else ItemType.h5ad, type=ItemType.annotation if is_annotation else ItemType.h5ad,
) )
@@ -174,10 +172,14 @@ class FileItemSource(ItemSource):
annotations_fullpath = self.full_path(annotations_subpath) annotations_fullpath = self.full_path(annotations_subpath)
if os.path.isdir(annotations_fullpath): if os.path.isdir(annotations_fullpath):
return [ return [
self.make_fileitem_from_path(annotation, annotations_subpath, True) self.make_fileitem_from_path(
annotation, annotations_subpath, True
)
for annotation in sorted(os.listdir(annotations_fullpath)) for annotation in sorted(os.listdir(annotations_fullpath))
if annotation.endswith(self.annotation_file_suffix) if annotation.endswith(self.annotation_file_suffix)
and os.path.isfile(os.path.join(annotations_fullpath, annotation)) and os.path.isfile(
os.path.join(annotations_fullpath, annotation)
)
] ]
else: else:
return None return None

View File

@@ -18,7 +18,9 @@ class ItemType(Enum):
class Item(ABC): class Item(ABC):
def __init__(self, name: str, type: ItemType, annotations: List["Item"] = None): def __init__(
self, name: str, type: ItemType, annotations: List["Item"] = None
):
self.name = name self.name = name
self.type = type self.type = type
self.annotations = annotations self.annotations = annotations

View File

@@ -7,15 +7,13 @@
# OR CONDITIONS OF ANY KIND, either express or implied. See the License for # OR CONDITIONS OF ANY KIND, either express or implied. See the License for
# the specific language governing permissions and limitations under the License. # the specific language governing permissions and limitations under the License.
from os.path import basename, dirname, join
from typing import List from typing import List
from os.path import join, dirname, basename
import s3fs
from cellxgene_gateway import dir_util from cellxgene_gateway import dir_util
import s3fs
from cellxgene_gateway.items.s3.s3item import S3Item
from cellxgene_gateway.items.item import ItemTree, ItemType from cellxgene_gateway.items.item import ItemTree, ItemType
from cellxgene_gateway.items.item_source import ItemSource, LookupResult from cellxgene_gateway.items.item_source import ItemSource, LookupResult
from cellxgene_gateway.items.s3.s3item import S3Item
class S3ItemSource(ItemSource): class S3ItemSource(ItemSource):
@@ -29,10 +27,6 @@ class S3ItemSource(ItemSource):
): ):
self._name = name self._name = name
self.s3 = s3fs.S3FileSystem() self.s3 = s3fs.S3FileSystem()
if bucket.startswith("s3://"):
raise Exception(
f"Bucket name should not include s3:// prefix, got {bucket}"
)
self.bucket = bucket self.bucket = bucket
self.h5ad_suffix = h5ad_suffix self.h5ad_suffix = h5ad_suffix
self.annotation_dir_suffix = annotation_dir_suffix self.annotation_dir_suffix = annotation_dir_suffix
@@ -71,14 +65,15 @@ class S3ItemSource(ItemSource):
raise Exception(f"S3 url '{url}' does not exist.") raise Exception(f"S3 url '{url}' does not exist.")
s3key_map = dict( s3key_map = dict(
(filepath[len(self.bucket) :].lstrip("/"), "s3://" + filepath) (filepath[len(self.bucket) :], "s3://" + filepath)
for filepath in sorted(self.s3.ls(url)) for filepath in sorted(self.s3.ls(url))
) )
def is_annotation_dir(dir_s3key): def is_annotation_dir(dir_s3key):
return ( return (
dir_s3key.endswith(self.annotation_dir_suffix) dir_s3key.endswith(self.annotation_dir_suffix)
and self.convert_annotation_key_to_h5ad(dir_s3key) in h5ad_paths and self.convert_annotation_key_to_h5ad(dir_s3key)
in h5ad_paths
) )
h5ad_paths = [ h5ad_paths = [
@@ -100,7 +95,8 @@ class S3ItemSource(ItemSource):
branches = None branches = None
if len(subdirs) > 0: if len(subdirs) > 0:
branches = [ branches = [
self.scan_directory(join(subpath, subdir)) for subdir in subdirs self.scan_directory(join(subpath, subdir))
for subdir in subdirs
] ]
return ItemTree(subpath, items, branches) return ItemTree(subpath, items, branches)
@@ -123,7 +119,9 @@ class S3ItemSource(ItemSource):
def lookup(self, indescriptor: str) -> LookupResult: def lookup(self, indescriptor: str) -> LookupResult:
descriptor = indescriptor.strip("/") descriptor = indescriptor.strip("/")
if descriptor.endswith(self.annotation_file_suffix): if descriptor.endswith(self.annotation_file_suffix):
annotation_item = self.shallowitem_from_descriptor(descriptor, True) annotation_item = self.shallowitem_from_descriptor(
descriptor, True
)
if not self.s3.exists(self.url(annotation_item.s3key)): if not self.s3.exists(self.url(annotation_item.s3key)):
with self.s3.open(self.url(annotation_item.s3key), "w") as f: with self.s3.open(self.url(annotation_item.s3key), "w") as f:
f.write("") f.write("")

View File

@@ -10,15 +10,14 @@
import logging import logging
import time import time
from cellxgene_gateway import env, util from cellxgene_gateway.env import ttl
from cellxgene_gateway.util import current_time_stamp
logger = logging.getLogger(__name__)
class PruneProcessCache: class PruneProcessCache:
def __init__(self, cache): def __init__(self, cache):
self.cache = cache self.cache = cache
self.expire_seconds = 3600 if env.ttl is None else int(env.ttl) self.expire_seconds = 3600 if ttl is None else int(ttl)
def __call__(self): def __call__(self):
while True: while True:
@@ -26,20 +25,24 @@ class PruneProcessCache:
self.prune() self.prune()
def prune(self): def prune(self):
timestamp = util.current_time_stamp() timestamp = current_time_stamp()
cutoff = timestamp - self.expire_seconds cutoff = timestamp - self.expire_seconds
processes_to_delete = [p for p in self.cache.entry_list if p.timestamp < cutoff] processes_to_delete = [
p for p in self.cache.entry_list if p.timestamp < cutoff
]
processes_to_keep = [ processes_to_keep = [
p for p in self.cache.entry_list if not p.timestamp < cutoff p for p in self.cache.entry_list if not p.timestamp < cutoff
] ]
logger = logging.getLogger("cellxgene_gateway")
logger.debug( logger.debug(
f"Cutoff {cutoff} = timestamp {timestamp} - expire seconds {self.expire_seconds} , keeping {processes_to_keep}, pruning {processes_to_delete}" f"Cutoff {cutoff} = timestamp {timestamp} - expire seconds {self.expire_seconds} , keeping {processes_to_keep}"
) )
for process in processes_to_delete: for process in processes_to_delete:
try: try:
logger.info(f"pruning process {process.pid} ({process.key.dataset})") logger.info(
f"pruning process {process.pid} ({process.key.dataset})"
)
self.cache.prune(process) self.cache.prune(process)
except Exception: except Exception:
logger.exception( logger.exception(

View File

@@ -1,5 +1,5 @@
// neandertal javascript // neandertal javascript
// Annotations only work with file itemsources at the moment. If they work with others in the future we may need to revisit this. // TODO: rewrite this --
const new_annotation_callback = (() =>{ const new_annotation_callback = (() =>{
const suffix = `.csv`; const suffix = `.csv`;
return (e) => { return (e) => {

View File

@@ -14,7 +14,11 @@ from flask_api import status
from cellxgene_gateway.cache_entry import CacheEntryStatus from cellxgene_gateway.cache_entry import CacheEntryStatus
from cellxgene_gateway.dir_util import make_annotations from cellxgene_gateway.dir_util import make_annotations
from cellxgene_gateway.env import cellxgene_args, enable_annotations, enable_backed_mode from cellxgene_gateway.env import (
enable_annotations,
enable_backed_mode,
cellxgene_args,
)
from cellxgene_gateway.process_exception import ProcessException from cellxgene_gateway.process_exception import ProcessException
@@ -22,10 +26,14 @@ class SubprocessBackend:
def __init__(self): def __init__(self):
pass pass
def create_cmd(self, cellxgene_loc, file_path, port, scripts, annotation_file_path): def create_cmd(
self, cellxgene_loc, file_path, port, scripts, annotation_file_path
):
if enable_annotations and not annotation_file_path is None: if enable_annotations and not annotation_file_path is None:
if annotation_file_path == "": if annotation_file_path == "":
extra_args = f" --annotations-dir {make_annotations(file_path)}" extra_args = (
f" --annotations-dir {make_annotations(file_path)}"
)
else: else:
extra_args = f" --annotations-file {annotation_file_path}" extra_args = f" --annotations-file {annotation_file_path}"
else: else:

View File

@@ -48,7 +48,7 @@
<tr> <tr>
<td>{{ entry.pid }}</td> <td>{{ entry.pid }}</td>
<td><a <td><a
href="{{ entry.key.view_url }}">{{ entry.key.h5ad_item.descriptor }}</a> href="{{ url_for('do_view', path=entry.key.descriptor, source_name=entry.key.source_name) }}">{{ entry.key.h5ad_item.descriptor }}</a>
</td> </td>
<td>{{ entry.key.annotation_descriptor }}</td> <td>{{ entry.key.annotation_descriptor }}</td>
<td>{{ entry.source_name }}</td> <td>{{ entry.source_name }}</td>

View File

@@ -28,11 +28,11 @@
<h4>{{ message }}</h4> <h4>{{ message }}</h4>
<a href="{{ url_for('filecrawl') }}"> <a href="/filecrawl.html">
Please click here to be redirected to the file directory. Please click here to be redirected to the file directory.
</a> </a>
<br> <br>
<a href="{{ url_for('index') }}"> <a href="/">
Please click here to return to the homepage. Please click here to return to the homepage.
</a> </a>
</div> </div>

View File

@@ -36,10 +36,10 @@
Navigation: Navigation:
<ul> <ul>
{% if path %} {% if path %}
<li><a href="{{ url_for('filecrawl') }}">top level</a></li> <li><a href="/filecrawl.html">top level</a></li>
{% else %} {% else %}
{% endif %} {% endif %}
<li><a href="{{ url_for('index') }}">homepage</a></li> <li><a href="/">homepage</a></li>
</ul> </ul>
</p> </p>
<script> <script>

View File

@@ -35,12 +35,12 @@
Links: Links:
</h1> </h1>
<div class="list-group" style="width:50%;padding-left:65px"> <div class="list-group" style="width:50%;padding-left:65px">
<a href="{{ url_for('filecrawl') }}" class="list-group-item list-group-item-action"> <a href="/filecrawl.html" class="list-group-item list-group-item-action">
<u>File Crawler: Allows you to view all uploaded data.</u></a> <u>File Crawler: Allows you to view all uploaded data.</u></a>
</div> </div>
<div class="list-group" style="width:50%;padding-left:65px"> <div class="list-group" style="width:50%;padding-left:65px">
<a href="{{ url_for('do_GET_status') }}" class="list-group-item list-group-item-action"> <a href="/cache_status" class="list-group-item list-group-item-action">
<u>Cache Status: view status of launched cellxgene servers.</u></a> <u>Cache Status: view status of launched cellxgene servers.</u></a>
</div> </div>

View File

@@ -35,11 +35,11 @@
The page will refresh shortly. The page will refresh shortly.
</p> </p>
<a href="{{ url_for('filecrawl') }}"> <a href="/filecrawl.html">
Please click here to be redirected to the file directory. Please click here to be redirected to the file directory.
</a> </a>
<br> <br>
<a href="{{ url_for('index') }}"> <a href="/">
Please click here to return to the homepage. Please click here to return to the homepage.
</a> </a>
</div> </div>

View File

@@ -36,13 +36,13 @@
<h4>Options</h4> <h4>Options</h4>
Please choose one of the following, or use the back button: Please choose one of the following, or use the back button:
<ul> <ul>
<li><a href="{{ relaunch_url }}"> <li><a href="{{url_for('do_relaunch', path=dataset)}}">
Attempt to relaunch the cellxgene server. Attempt to relaunch the cellxgene server.
</a></li> </a></li>
<li><a href="{{ url_for('filecrawl') }}"> <li><a href="/filecrawl.html">
Return to the file directory. Return to the file directory.
</a></li> </a></li>
<li><a href="{{ url_for('index') }}"> <li><a href="/">
Return to the homepage. Return to the homepage.
</a></li> </a></li>
</ul> </ul>

View File

@@ -7,7 +7,6 @@ dependencies:
- flask - flask
- psutil - psutil
- black - black
- coverage
- pip - pip
- pip: - pip:
- flask-api - flask-api

View File

@@ -1,22 +0,0 @@
import tempfile
import unittest
from cellxgene_gateway.items.file.fileitem_source import FileItemSource
class TestFileItemSource(unittest.TestCase):
def test_make_fileitem_from_path_GIVEN_annotation_file_THEN_name_lacks_csv(
self,
):
source = FileItemSource(tempfile.gettempdir(), "local")
item = source.make_fileitem_from_path(
"customanno.csv", "someh5ad_annotations", True
)
self.assertEqual(item.name, "customanno")
self.assertEqual(item.descriptor, "someh5ad_annotations/customanno.csv")
def test_make_fileitem_from_path_GIVEN_h5ad_file_THEN_returns_name(self):
source = FileItemSource(tempfile.gettempdir(), "local")
item = source.make_fileitem_from_path("someanalysis.h5ad", "studydir")
self.assertEqual(item.name, "someanalysis.h5ad")
self.assertEqual(item.descriptor, "studydir/someanalysis.h5ad")

View File

@@ -1,62 +1,33 @@
import unittest import unittest
from flask import Flask
from cellxgene_gateway import flask_util
from cellxgene_gateway.cache_entry import CacheEntry, CacheEntryStatus from cellxgene_gateway.cache_entry import CacheEntry, CacheEntryStatus
from cellxgene_gateway.cache_key import CacheKey from cellxgene_gateway.cache_key import CacheKey
from cellxgene_gateway.gateway import app from cellxgene_gateway.items.item import ItemType
from cellxgene_gateway.items.file.fileitem import FileItem from cellxgene_gateway.items.file.fileitem import FileItem
from cellxgene_gateway.items.file.fileitem_source import FileItemSource from cellxgene_gateway.items.file.fileitem_source import FileItemSource
from cellxgene_gateway.items.item import ItemType
key = CacheKey( key = CacheKey(
FileItem("/czi/", name="pbmc3k.h5ad", type=ItemType.h5ad), FileItem("/czi/", "pbmc3k.h5ad", ItemType.h5ad),
FileItemSource("/tmp", "local"), FileItemSource("/tmp", "local"),
) )
class TestRenderEntry(unittest.TestCase): class TestRenderEntry(unittest.TestCase):
def setUp(self):
self.app = app
self.app_context = self.app.test_request_context()
self.app_context.push()
self.client = self.app.test_client()
def test_GIVEN_key_and_port_THEN_returns_loading_CacheEntry(self): def test_GIVEN_key_and_port_THEN_returns_loading_CacheEntry(self):
entry = CacheEntry.for_key("some-key", 1) entry = CacheEntry.for_key("some-key", 1)
self.assertEqual(entry.status, CacheEntryStatus.loading) self.assertEqual(entry.status, CacheEntryStatus.loading)
def test_GIVEN_absolute_static_url_THEN_include_path(self): def test_GIVEN_absolute_static_url_THEN_include_path(self):
flask_util.include_source_in_url = False
actual = CacheEntry.for_key(key, 8000).rewrite_text_content( actual = CacheEntry.for_key(key, 8000).rewrite_text_content(
"src:url(/static/assets/" "src:url(/static/assets/"
) )
expected = "src:url(/view/czi/pbmc3k.h5ad/static/assets/" expected = "src:url(http://localhost:5005/source/local/view/czi/pbmc3k.h5ad/static/assets/"
self.assertEqual(actual, expected) self.assertEqual(actual, expected)
def test_GIVEN_absolute_src_THEN_include_path(self): def test_GIVEN_absolute_src_THEN_include_path(self):
flask_util.include_source_in_url = False
actual = CacheEntry.for_key(key, 8000).rewrite_text_content( actual = CacheEntry.for_key(key, 8000).rewrite_text_content(
'<link rel="shortcut icon" href="/static/assets/favicon.ico">' '<link rel="shortcut icon" href="/static/assets/favicon.ico">'
) )
expected = '<link rel="shortcut icon" href="/view/czi/pbmc3k.h5ad/static/assets/favicon.ico">' expected = '<link rel="shortcut icon" href="http://localhost:5005/source/local/view/czi/pbmc3k.h5ad/static/assets/favicon.ico">'
self.assertEqual(actual, expected)
def test_GIVEN_absolute_static_url_include_source_THEN_include_path(self):
flask_util.include_source_in_url = True
actual = CacheEntry.for_key(key, 8000).rewrite_text_content(
"src:url(/static/assets/"
)
expected = "src:url(/source/local/view/czi/pbmc3k.h5ad/static/assets/"
self.assertEqual(actual, expected)
def test_GIVEN_absolute_src_include_source_THEN_include_path(self):
flask_util.include_source_in_url = True
actual = CacheEntry.for_key(key, 8000).rewrite_text_content(
'<link rel="shortcut icon" href="/static/assets/favicon.ico">'
)
expected = '<link rel="shortcut icon" href="/source/local/view/czi/pbmc3k.h5ad/static/assets/favicon.ico">'
self.assertEqual(actual, expected) self.assertEqual(actual, expected)

View File

@@ -2,16 +2,18 @@ import unittest
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from cellxgene_gateway.filecrawl import render_item from cellxgene_gateway.filecrawl import render_item
from cellxgene_gateway.items.item import ItemType
from cellxgene_gateway.items.file.fileitem import FileItem from cellxgene_gateway.items.file.fileitem import FileItem
from cellxgene_gateway.items.file.fileitem_source import FileItemSource from cellxgene_gateway.items.file.fileitem_source import FileItemSource
from cellxgene_gateway.items.item import ItemType
source = FileItemSource("/tmp") source = FileItemSource("/tmp")
class TestRenderEntry(unittest.TestCase): class TestRenderEntry(unittest.TestCase):
def test_GIVEN_path_both_slash_THEN_view_has_single_slash(self): def test_GIVEN_path_both_slash_THEN_view_has_single_slash(self):
entry = FileItem(subpath="/somepath/", name="entry", type=ItemType.h5ad) entry = FileItem(
subpath="/somepath/", name="entry", type=ItemType.h5ad
)
rendered = render_item(entry, source) rendered = render_item(entry, source)
self.assertIn("view/somepath/entry/'", rendered) self.assertIn("view/somepath/entry/'", rendered)

View File

@@ -1,42 +0,0 @@
import unittest
from unittest.mock import MagicMock, patch
from cellxgene_gateway.backend_cache import BackendCache
from cellxgene_gateway.cache_entry import CacheEntry
from cellxgene_gateway.cache_key import CacheKey
from cellxgene_gateway.items.file.fileitem import FileItem
from cellxgene_gateway.items.file.fileitem_source import FileItemSource
from cellxgene_gateway.items.item import ItemType
from cellxgene_gateway.process_exception import ProcessException
class TestSubprocessBackend(unittest.TestCase):
@patch("subprocess.Popen")
def test_launch_GIVEN_no_stdout_THEN_throw_ProcessException(self, popen):
subprocess = MagicMock()
subprocess.stdout.readline().decode.return_value = ""
subprocess.stderr.read().decode.return_value = "An unexpected error"
popen.return_value = subprocess
key = CacheKey(
FileItem("/czi/", name="pbmc3k.h5ad", type=ItemType.h5ad),
FileItemSource("/tmp", "local"),
)
entry = CacheEntry.for_key(key, 8000)
from cellxgene_gateway.subprocess_backend import SubprocessBackend
backend = SubprocessBackend()
cellxgene_loc = "/some/cellxgene"
scripts = ["http://example.com/script.js", "http://example.com/script2.js"]
with self.assertRaises(ProcessException) as context:
backend.launch(cellxgene_loc, scripts, entry)
popen.assert_called_once_with(
[
"yes | /some/cellxgene launch /tmp/czi/pbmc3k.h5ad --port 8000 --host 127.0.0.1 --disable-annotations --scripts http://example.com/script.js --scripts http://example.com/script2.js"
],
shell=True,
stderr=-1,
stdout=-1,
)
self.assertEqual("An unexpected error", context.exception.stderr)