mirror of
https://github.com/Novartis/cellxgene-gateway.git
synced 2026-09-16 05:17:55 +08:00
Compare commits
150 Commits
prune_pid
...
79_add_doc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f296efcc55 | ||
|
|
facfb27d5c | ||
|
|
390fe24ea4 | ||
|
|
590565bea2 | ||
|
|
d32a31e855 | ||
|
|
0cd551382e | ||
|
|
36c0a4d3d7 | ||
|
|
8d8a0a3483 | ||
|
|
eaa157079c | ||
|
|
977c50ce8c | ||
|
|
833cad3bc2 | ||
|
|
c5f3c68740 | ||
|
|
d944a31d59 | ||
|
|
5814cb9943 | ||
|
|
9a91cdf795 | ||
|
|
25aff5c020 | ||
|
|
6bcb594712 | ||
|
|
f9ed4c4047 | ||
|
|
a9753c4101 | ||
|
|
fd48920c5b | ||
|
|
09db93b2b5 | ||
|
|
3e3bd22512 | ||
|
|
893b2f1af1 | ||
|
|
757487b772 | ||
|
|
87a8dbfa78 | ||
|
|
9c38e48c5c | ||
|
|
073f5f945c | ||
|
|
9dc4409f1a | ||
|
|
551cb46af8 | ||
|
|
620181ae4d | ||
|
|
73a7920cc8 | ||
|
|
ed3e999cd1 | ||
|
|
2ae2e53863 | ||
|
|
fd0e7d9c31 | ||
|
|
98ef6efd0c | ||
|
|
82e43ff943 | ||
|
|
f8a77423eb | ||
|
|
0375a717c9 | ||
|
|
4bf57832a0 | ||
|
|
b7d14dba6a | ||
|
|
26286f94b1 | ||
|
|
264a324946 | ||
|
|
520069a825 | ||
|
|
3cb0e4d725 | ||
|
|
d2b508e371 | ||
|
|
e74f6d01d1 | ||
|
|
7b799d0159 | ||
|
|
1f0885afdd | ||
|
|
a3a1a2d095 | ||
|
|
b4739b0cbb | ||
|
|
c29e5c0d92 | ||
|
|
a3e3b6cea8 | ||
|
|
687dc31b7a | ||
|
|
68da42ae0e | ||
|
|
ccf1ba58a8 | ||
|
|
d072034911 | ||
|
|
1200629d74 | ||
|
|
dba13ccacd | ||
|
|
3239aefad4 | ||
|
|
dd727a9469 | ||
|
|
ae5cf09d04 | ||
|
|
ae35d24d80 | ||
|
|
ad3517f002 | ||
|
|
2cefc6d741 | ||
|
|
1586c9a3e8 | ||
|
|
f16bd5e2b9 | ||
|
|
10105c43a8 | ||
|
|
638923fb43 | ||
|
|
169da934b1 | ||
|
|
33331c9198 | ||
|
|
d2d22cecaa | ||
|
|
5c488d8d5e | ||
|
|
b1ae9e8ce8 | ||
|
|
76c1d9e80c | ||
|
|
586dc6623a | ||
|
|
76f3939fdd | ||
|
|
5404a8b0f3 | ||
|
|
6f2b372d32 | ||
|
|
25755d3f69 | ||
|
|
893092f199 | ||
|
|
d3ee04b6e5 | ||
|
|
f1f4b0c0ca | ||
|
|
01dccef7db | ||
|
|
9c17e2ff32 | ||
|
|
fbc18fb636 | ||
|
|
8c5a635de9 | ||
|
|
94062c2d64 | ||
|
|
068e8f7633 | ||
|
|
16b54f9409 | ||
|
|
942410bb44 | ||
|
|
0d084a405e | ||
|
|
49d679e779 | ||
|
|
5e6faa4b02 | ||
|
|
9fe846c786 | ||
|
|
1c7907aabd | ||
|
|
30d2b07b1a | ||
|
|
21ff56ea8b | ||
|
|
0b866a46ec | ||
|
|
d5246d4b4b | ||
|
|
465373ca7e | ||
|
|
fc83523c84 | ||
|
|
4e367b0ec9 | ||
|
|
ce3eb0eedf | ||
|
|
9219336356 | ||
|
|
b689357f29 | ||
|
|
5349df36e8 | ||
|
|
13802b72b9 | ||
|
|
0f8092e07a | ||
|
|
61c7c43da7 | ||
|
|
0604a0ca9f | ||
|
|
8cdd23a57b | ||
|
|
486206bd32 | ||
|
|
4105fc32e1 | ||
|
|
59cdac5f85 | ||
|
|
bd1b7e8da2 | ||
|
|
bfa80f7ef8 | ||
|
|
63cdfe1953 | ||
|
|
578845b133 | ||
|
|
849c8a58f2 | ||
|
|
021d981bef | ||
|
|
df812f3203 | ||
|
|
804ea29f36 | ||
|
|
da99abdd9f | ||
|
|
bf221309d8 | ||
|
|
3c53a8a4e6 | ||
|
|
3ea5c29265 | ||
|
|
64d1799133 | ||
|
|
de8dfb0a39 | ||
|
|
50d71e5bda | ||
|
|
7c7fa5f12a | ||
|
|
eb4693f1fa | ||
|
|
84db11077a | ||
|
|
f8e619c53e | ||
|
|
9a130a8eb7 | ||
|
|
11bbc1aba8 | ||
|
|
186ccde277 | ||
|
|
2b65c095a6 | ||
|
|
bae42edd3f | ||
|
|
cc09ac3b95 | ||
|
|
96ac41d860 | ||
|
|
525a9691c3 | ||
|
|
9f1d217e50 | ||
|
|
6c611f55dd | ||
|
|
11efd3a954 | ||
|
|
201f876341 | ||
|
|
736541c4ec | ||
|
|
c03098af17 | ||
|
|
f41db75a24 | ||
|
|
876263c046 | ||
|
|
9532375eb0 |
13
.coveragerc
Normal file
13
.coveragerc
Normal file
@@ -0,0 +1,13 @@
|
||||
[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/*
|
||||
67
.github/workflows/codeql-analysis.yml
vendored
Normal file
67
.github/workflows/codeql-analysis.yml
vendored
Normal file
@@ -0,0 +1,67 @@
|
||||
# For most projects, this workflow file will not need changing; you simply need
|
||||
# to commit it to your repository.
|
||||
#
|
||||
# You may wish to alter this file to override the set of languages analyzed,
|
||||
# or to provide custom queries or build logic.
|
||||
#
|
||||
# ******** NOTE ********
|
||||
# We have attempted to detect the languages in your repository. Please check
|
||||
# the `language` matrix defined below to confirm you have the correct set of
|
||||
# supported CodeQL languages.
|
||||
#
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [ master ]
|
||||
schedule:
|
||||
- cron: '18 6 * * 6'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'javascript', 'python' ]
|
||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
|
||||
# Learn more:
|
||||
# https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v1
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v1
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
|
||||
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
|
||||
# and modify them (or add more) to build your code if your project
|
||||
# uses a compiled language
|
||||
|
||||
#- run: |
|
||||
# make bootstrap
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v1
|
||||
68
.github/workflows/pr-checks.yaml
vendored
Normal file
68
.github/workflows/pr-checks.yaml
vendored
Normal file
@@ -0,0 +1,68 @@
|
||||
# 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
|
||||
pip install markupsafe==2.0.1 # temporary workaround for jinja2-2.11.3 calling soft_unicode in markupsafe
|
||||
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 41
|
||||
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
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -55,6 +55,7 @@ htmlcov/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
htmlcov
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
@@ -136,3 +137,6 @@ dmypy.json
|
||||
.pyre/
|
||||
|
||||
# End of https://www.gitignore.io/api/python
|
||||
|
||||
*.patch
|
||||
.vscode
|
||||
|
||||
@@ -7,14 +7,7 @@ repos:
|
||||
language: system
|
||||
types: [python]
|
||||
stages: [commit]
|
||||
- id: flake8
|
||||
name: flake8
|
||||
language: system
|
||||
entry: flake8
|
||||
types: [python]
|
||||
stages: [commit]
|
||||
- id: black
|
||||
language_version: python3.6+
|
||||
name: black
|
||||
language: system
|
||||
entry: black
|
||||
|
||||
41
.travis.yml
Normal file
41
.travis.yml
Normal file
@@ -0,0 +1,41 @@
|
||||
# This is necessary for nxviz as matplotlib is involved.
|
||||
# before_script:
|
||||
# - "export DISPLAY=:99.0"
|
||||
# - "sh -e /etc/init.d/xvfb start"
|
||||
# - sleep 5 # give xvfb some time to start
|
||||
|
||||
language: python
|
||||
matrix:
|
||||
include:
|
||||
- python: 3.5 # we don't actually use this
|
||||
env: PYTHON_VERSION=3.7
|
||||
|
||||
install:
|
||||
# We do this conditionally because it saves us some downloading if the
|
||||
# version is the same.
|
||||
- wget https://repo.continuum.io/miniconda/Miniconda3-latest-Linux-x86_64.sh -O miniconda.sh;
|
||||
- bash miniconda.sh -b -p $HOME/miniconda
|
||||
- export PATH="$HOME/miniconda/bin:$PATH"
|
||||
- hash -r
|
||||
- conda config --set always_yes yes --set changeps1 no
|
||||
- conda update -q conda
|
||||
- conda config --add channels conda-forge
|
||||
|
||||
# Useful for debugging any issues with conda
|
||||
- conda info -a
|
||||
|
||||
# Install Python, py.test, and required packages.
|
||||
- conda env create -f environment.yml
|
||||
- source activate cellxgene-gateway
|
||||
- python setup.py install
|
||||
|
||||
script:
|
||||
# Your test script goes here
|
||||
- black -l 79 . --check
|
||||
- python -m unittest discover tests
|
||||
|
||||
after_success:
|
||||
- bash <(curl -s https://codecov.io/bash)
|
||||
|
||||
notifications:
|
||||
email: true
|
||||
73
Changelog.md
Normal file
73
Changelog.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# 0.3.10
|
||||
|
||||
* #65 Added GATEWAY_EXPIRE_SECONDS to set how long cellxgene servers can remain idle before being terminated.
|
||||
* Added GATEWAY_LOG_LEVEL to set the log level
|
||||
* #68 Close connections after reading response
|
||||
* #68 Background thread reads from output of cellxgene process until it exits
|
||||
|
||||
# 0.3.9
|
||||
|
||||
* Added S3_ENABLE_LISTINGS_CACHE variable (See README.md)
|
||||
|
||||
# 0.3.8
|
||||
|
||||
* Fixed bug #57 affecting deeply nested subdirectory listing
|
||||
|
||||
# 0.3.7
|
||||
|
||||
* added back /metadata/ip_address endpoint
|
||||
|
||||
# 0.3.6
|
||||
|
||||
* pinned version of werkzeug
|
||||
|
||||
# 0.3.5
|
||||
|
||||
* Pinned flask version to match cellxgene 0.17.0
|
||||
|
||||
# 0.3.4
|
||||
|
||||
* Fixed bug #50 affecting subdirectory listing
|
||||
|
||||
# 0.3.3
|
||||
|
||||
* Fixed bug #48 affecting cache pruning
|
||||
|
||||
# 0.3.2
|
||||
|
||||
* Fixed bug #45 affecting multi-level S3 folders
|
||||
* Added extra_scripts to cache_status page
|
||||
|
||||
# 0.3.1
|
||||
|
||||
* Added missing __init__.py
|
||||
|
||||
# 0.3.0
|
||||
|
||||
* Added support for itemsource interface, allowing s3 hosting
|
||||
* Removed support for http file uploads
|
||||
* Only set wsgi.url_scheme when EXTERNAL_PROTOCOL is set (see issue #43)
|
||||
* Dropped flake8 due to conflicts with black
|
||||
* Added code coverage metrics
|
||||
|
||||
# 0.2.3
|
||||
|
||||
* Added support for ProxyFix
|
||||
|
||||
# 0.2.2
|
||||
|
||||
* Fixed bug with annotations (missing annotation.js asset)
|
||||
|
||||
# 0.2.1
|
||||
|
||||
* Minor fixes to enable cellxgene 0.16.0
|
||||
* Added CELLXGENE_ARGS to enable passing additional arguments to cellxgene
|
||||
* added metadata/ip_address endpoint
|
||||
|
||||
# 0.2.0
|
||||
|
||||
Incrementing minor version since the changes for 0.15 are breaking, and we may want to release bugfixes from 0.1.0 branch.
|
||||
|
||||
# 0.1.1
|
||||
|
||||
Added support for cellxgene 0.15
|
||||
8
Dockerfile
Normal file
8
Dockerfile
Normal file
@@ -0,0 +1,8 @@
|
||||
FROM python:3.9
|
||||
|
||||
RUN pip install cellxgene-gateway 'MarkupSafe<2.1'
|
||||
|
||||
ENV CELLXGENE_DATA=/cellxgene-data
|
||||
ENV CELLXGENE_LOCATION=/usr/local/bin/cellxgene
|
||||
|
||||
CMD ["cellxgene-gateway"]
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
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.
|
||||
|
||||
[](https://codecov.io/gh/Novartis/cellxgene-gateway) [](https://pypi.org/project/cellxgene-gateway/) [](https://pypistats.org/packages/cellxgene-gateway)
|
||||
|
||||
# Running locally
|
||||
|
||||
## Prequisites
|
||||
@@ -30,7 +32,7 @@ Note: you may need to downgrade h5py with `pip install h5py==2.9.0` due to an [i
|
||||
### Option 2: Install from PyPI
|
||||
|
||||
```bash
|
||||
# NOT YET DONE, COMING! STAY TUNED
|
||||
pip install cellxgene-gateway
|
||||
```
|
||||
|
||||
## Running cellxgene gateway
|
||||
@@ -39,7 +41,7 @@ Note: you may need to downgrade h5py with `pip install h5py==2.9.0` due to an [i
|
||||
|
||||
```bash
|
||||
mkdir ../cellxgene_data
|
||||
wget https://github.com/chanzuckerberg/cellxgene/raw/master/example-dataset/pbmc3k.h5ad -O ../cellxgene_data/pbmc3k.h5ad
|
||||
wget https://raw.githubusercontent.com/chanzuckerberg/cellxgene/master/example-dataset/pbmc3k.h5ad -O ../cellxgene_data/pbmc3k.h5ad
|
||||
```
|
||||
|
||||
|
||||
@@ -48,9 +50,6 @@ wget https://github.com/chanzuckerberg/cellxgene/raw/master/example-dataset/pbmc
|
||||
```bash
|
||||
export CELLXGENE_DATA=../cellxgene_data # change this directory if you put data in a different place.
|
||||
export CELLXGENE_LOCATION=`which cellxgene`
|
||||
export GATEWAY_HOST=localhost:5005
|
||||
export GATEWAY_PROTOCOL=http
|
||||
export GATEWAY_IP=127.0.0.1
|
||||
```
|
||||
|
||||
3. Now, execute the cellxgene gateway:
|
||||
@@ -62,16 +61,61 @@ cellxgene-gateway
|
||||
Here's what the environment variables mean:
|
||||
|
||||
* `CELLXGENE_LOCATION` - the location of the cellxgene executable, e.g. `~/anaconda2/envs/cellxgene/bin/cellxgene`
|
||||
|
||||
At least one of the following is required:
|
||||
* `CELLXGENE_DATA` - a directory that can contain subdirectories with `.h5ad` data files, *without* trailing slash, e.g. `/mnt/cellxgene_data`
|
||||
* `GATEWAY_HOST` - the hostname and port that the gateway will run on, typically `localhost:5005` if running locally
|
||||
* `GATEWAY_PROTOCOL` - typically http when running locally, can be https when deployed if the gateway is behind a load balancer or reverse proxy.
|
||||
* `GATEWAY_IP` - ip addess of instance gateway is running on, mostly used to display SSH instructions
|
||||
* `CELLXGENE_BUCKET` - an s3 bucket that can contain keys with `.h5ad` data files, e.g. `my-cellxgene-data-bucket`
|
||||
Cellxgene Gateway is designed to make it easy to add additional data sources, please see the source code for gateway.py and the ItemSource interface in items/item_source.py
|
||||
|
||||
Optional environment variables:
|
||||
* `CELLXGENE_ARGS` - catch-all variable that can be used to pass additional command line args to cellxgene server
|
||||
* `EXTERNAL_HOST` - the hostname and port from the perspective of the web browser, typically `localhost:5005` if running locally. Defaults to "localhost:{GATEWAY_PORT}"
|
||||
* `EXTERNAL_PROTOCOL` - typically http when running locally, can be https when deployed if the gateway is behind a load balancer or reverse proxy that performs https termination. Default value "http"
|
||||
* `GATEWAY_IP` - ip addess of instance gateway is running on, mostly used to display SSH instructions. Defaults to `socket.gethostbyname(socket.gethostname())`
|
||||
* `GATEWAY_PORT` - local port that the gateway should bind to, defaults to 5005
|
||||
* `GATEWAY_EXPIRE_SECONDS` - time in seconds that a cellxgene process will remain idle before being terminated. Defaults to 3600 (one hour)
|
||||
* `GATEWAY_EXTRA_SCRIPTS` - JSON array of script paths, will be embedded into each page and forwarded with `--scripts` to cellxgene server
|
||||
* `GATEWAY_ENABLE_UPLOAD` - Set to `true` or `1` to enable HTTP uploads. This is not recommended for a public server.
|
||||
* `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_LOG_LEVEL` - default is `INFO`. set to `DEBUG` to increase logging and to `WARNING` to decrease logging.
|
||||
* `S3_ENABLE_LISTINGS_CACHE` - Set to `true` or to `1` to cache listings of S3 folders for performance. If the cache becomes stale, set `filecrawl.html?refresh=true` query parameter to refresh the cache.
|
||||
|
||||
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.
|
||||
|
||||
## Running cellxgene-gateway with Docker
|
||||
|
||||
First, build Docker image:
|
||||
|
||||
```bash
|
||||
docker build -t cellxgene-gateway .
|
||||
```
|
||||
|
||||
Then, cellxgene-gateway can be launched as such:
|
||||
|
||||
```bash
|
||||
docker run -it --rm \
|
||||
-v <local_data_dir>:/cellxgene-data \
|
||||
-p 5005:5005 \
|
||||
cellxgene-gateway
|
||||
```
|
||||
|
||||
Additional environment variables can be provided with the `-e` parameter:
|
||||
|
||||
```bash
|
||||
docker run -it --rm \
|
||||
-v <local_data_dir>:/cellxgene-data \
|
||||
-e GATEWAY_PORT=8080 \
|
||||
-p 8080:8080 \
|
||||
cellxgene-gateway
|
||||
```
|
||||
|
||||
# Customization
|
||||
|
||||
The current paradigm for customization is to modify files during a build or deployment phase:
|
||||
@@ -112,20 +156,35 @@ python setup.py develop
|
||||
|
||||
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
|
||||
|
||||
[](https://travis-ci.org/Novartis/cellxgene-gateway)
|
||||
|
||||
```bash
|
||||
python -m unittest discover tests
|
||||
```
|
||||
|
||||
## Code Coverage
|
||||
```bash
|
||||
coverage run -m unittest discover tests
|
||||
coverage html
|
||||
```
|
||||
|
||||
## Running Linters
|
||||
|
||||
pip install isort flake8 black
|
||||
|
||||
```bash
|
||||
isort -rc .
|
||||
flake8 .
|
||||
black -l 79 .
|
||||
isort -rc . # rc means recursive, and was deprecated in dev version of isort
|
||||
black .
|
||||
```
|
||||
|
||||
# Getting Help
|
||||
15
SECURITY.md
Normal file
15
SECURITY.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Use this section to tell people about which versions of your project are
|
||||
currently being supported with security updates.
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 0.3.2 | :white_check_mark: |
|
||||
| <= 0.3.1 | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please file a bug report issue.
|
||||
2
cellxgene_gateway/__init__.py
Executable file → Normal file
2
cellxgene_gateway/__init__.py
Executable file → Normal file
@@ -6,3 +6,5 @@
|
||||
# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES
|
||||
# OR CONDITIONS OF ANY KIND, either express or implied. See the License for
|
||||
# the specific language governing permissions and limitations under the License.
|
||||
|
||||
__version__ = "0.3.10"
|
||||
|
||||
@@ -9,11 +9,13 @@
|
||||
|
||||
import time
|
||||
from threading import Thread
|
||||
from typing import List
|
||||
|
||||
from flask_api import status
|
||||
|
||||
from cellxgene_gateway import env
|
||||
from cellxgene_gateway.cache_entry import CacheEntry
|
||||
from cellxgene_gateway.cache_entry import CacheEntry, CacheEntryStatus
|
||||
from cellxgene_gateway.cache_key import CacheKey
|
||||
from cellxgene_gateway.cellxgene_exception import CellxgeneException
|
||||
from cellxgene_gateway.subprocess_backend import SubprocessBackend
|
||||
|
||||
@@ -22,8 +24,10 @@ process_backend = SubprocessBackend()
|
||||
|
||||
def is_port_in_use(port):
|
||||
import socket
|
||||
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
return s.connect_ex(('localhost', port)) == 0
|
||||
return s.connect_ex(("localhost", port)) == 0
|
||||
|
||||
|
||||
class BackendCache:
|
||||
def __init__(self):
|
||||
@@ -33,12 +37,14 @@ class BackendCache:
|
||||
contents = self.entry_list
|
||||
return [c.port for c in contents]
|
||||
|
||||
def check_entry(self, dataset):
|
||||
def check_path(self, source, path):
|
||||
contents = self.entry_list
|
||||
matches = [
|
||||
c
|
||||
for c in contents
|
||||
if c.dataset == dataset and c.status != "terminated"
|
||||
if c.key.source.name == source.name
|
||||
and path.startswith(c.key.descriptor)
|
||||
and c.status != CacheEntryStatus.terminated
|
||||
]
|
||||
|
||||
if len(matches) == 0:
|
||||
@@ -48,16 +54,35 @@ class BackendCache:
|
||||
else:
|
||||
raise CellxgeneException(
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
"Found " + str(len(matches)) + " for " + dataset,
|
||||
"Found " + str(len(matches)) + " for " + path,
|
||||
)
|
||||
|
||||
def create_entry(self, dataset, file_path, scripts):
|
||||
def check_entry(self, key):
|
||||
contents = self.entry_list
|
||||
matches = [
|
||||
c
|
||||
for c in contents
|
||||
if c.key.equals(key) and c.status != CacheEntryStatus.terminated
|
||||
]
|
||||
|
||||
if len(matches) == 0:
|
||||
return None
|
||||
elif len(matches) == 1:
|
||||
return matches[0]
|
||||
else:
|
||||
raise CellxgeneException(
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
"Found " + str(len(matches)) + " for " + key.dataset,
|
||||
)
|
||||
|
||||
def create_entry(self, key: CacheKey, scripts: List[str]):
|
||||
port = 8000
|
||||
existing_ports = self.get_ports()
|
||||
|
||||
while (port in existing_ports) or is_port_in_use(port):
|
||||
port += 1
|
||||
|
||||
entry = CacheEntry.for_dataset(dataset, file_path, port)
|
||||
entry = CacheEntry.for_key(key, port)
|
||||
|
||||
background_thread = Thread(
|
||||
target=process_backend.launch,
|
||||
|
||||
@@ -6,34 +6,47 @@
|
||||
# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES
|
||||
# OR CONDITIONS OF ANY KIND, either express or implied. See the License for
|
||||
# the specific language governing permissions and limitations under the License.
|
||||
import psutil
|
||||
import datetime
|
||||
import logging
|
||||
import re
|
||||
from enum import Enum
|
||||
|
||||
from flask import make_response, request
|
||||
import psutil
|
||||
from flask import make_response, render_template, request
|
||||
from flask.wrappers import Response
|
||||
from requests import get, post, put
|
||||
|
||||
from cellxgene_gateway import env
|
||||
from cellxgene_gateway.cellxgene_exception import CellxgeneException
|
||||
from cellxgene_gateway.flask_util import querystring
|
||||
from cellxgene_gateway.util import current_time_stamp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CacheEntryStatus(Enum):
|
||||
loaded = "loaded"
|
||||
loading = "loading"
|
||||
error = "error"
|
||||
terminated = "terminated"
|
||||
|
||||
|
||||
class CacheEntry:
|
||||
def __init__(
|
||||
self,
|
||||
pid,
|
||||
dataset,
|
||||
file_path,
|
||||
key,
|
||||
port,
|
||||
launchtime,
|
||||
timestamp,
|
||||
status,
|
||||
status: CacheEntryStatus,
|
||||
message,
|
||||
all_output,
|
||||
stderr,
|
||||
http_status,
|
||||
):
|
||||
self.pid = pid
|
||||
self.dataset = dataset
|
||||
self.file_path = file_path
|
||||
self.key = key
|
||||
self.port = port
|
||||
self.launchtime = launchtime
|
||||
self.timestamp = timestamp
|
||||
@@ -44,30 +57,34 @@ class CacheEntry:
|
||||
self.http_status = http_status
|
||||
|
||||
@classmethod
|
||||
def for_dataset(cls, dataset, file_path, port):
|
||||
def for_key(cls, key, port):
|
||||
|
||||
return cls(
|
||||
None,
|
||||
dataset,
|
||||
file_path,
|
||||
key,
|
||||
port,
|
||||
current_time_stamp(),
|
||||
current_time_stamp(),
|
||||
"loading",
|
||||
CacheEntryStatus.loading,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
|
||||
@property
|
||||
def source_name(self):
|
||||
return self.key.source_name
|
||||
|
||||
def set_loaded(self, pid):
|
||||
self.pid = pid
|
||||
self.status = "loaded"
|
||||
self.status = CacheEntryStatus.loaded
|
||||
|
||||
def set_error(self, message, stderr, http_status):
|
||||
self.message = message
|
||||
self.stderr = stderr
|
||||
self.http_status = http_status
|
||||
self.status = "error"
|
||||
self.status = CacheEntryStatus.error
|
||||
|
||||
def append_output(self, output):
|
||||
if self.all_output == None:
|
||||
@@ -77,79 +94,119 @@ class CacheEntry:
|
||||
|
||||
def terminate(self):
|
||||
pid = self.pid
|
||||
if pid != None and self.status != "terminated":
|
||||
if pid != None and self.status != CacheEntryStatus.terminated:
|
||||
terminated = []
|
||||
|
||||
def on_terminate(p):
|
||||
terminated.append(p.pid)
|
||||
|
||||
p = psutil.Process(pid)
|
||||
children = p.children()
|
||||
for child in children:
|
||||
child.terminate()
|
||||
psutil.wait_procs(children, callback=on_terminate)
|
||||
terminated.append(p.pid)
|
||||
p.terminate()
|
||||
psutil.wait_procs([p], callback=on_terminate)
|
||||
logging.getLogger("cellxgene_gateway").info(f"terminated {terminated}")
|
||||
self.status = "terminated"
|
||||
# the parent process may automatically die once its children have --
|
||||
try:
|
||||
p.terminate()
|
||||
psutil.wait_procs([p], callback=on_terminate)
|
||||
except psutil.NoSuchProcess:
|
||||
pass
|
||||
|
||||
logger.info(f"terminated {terminated}")
|
||||
self.status = CacheEntryStatus.terminated
|
||||
|
||||
def rewrite_text_content(self, cellxgene_content):
|
||||
# for v0.16.0 compatibility, see issue #24
|
||||
gateway_content = (
|
||||
re.sub(
|
||||
'(="|\()/static/',
|
||||
f"\\1{self.key.gateway_basepath()}static/",
|
||||
cellxgene_content,
|
||||
)
|
||||
.replace("http://fonts.gstatic.com", "https://fonts.gstatic.com")
|
||||
.replace(self.cellxgene_basepath(), self.key.gateway_basepath())
|
||||
)
|
||||
return gateway_content
|
||||
|
||||
def cellxgene_basepath(self):
|
||||
return f"http://127.0.0.1:{self.port}"
|
||||
|
||||
def serve_content(self, path):
|
||||
dataset = self.dataset
|
||||
|
||||
gateway_basepath = (
|
||||
f"{env.gateway_protocol}://{env.gateway_host}/view/{dataset}/"
|
||||
)
|
||||
subpath = path[len(dataset) :] # noqa: E203
|
||||
|
||||
gateway_basepath = self.key.gateway_basepath()
|
||||
subpath = path[len(self.key.descriptor) :] # noqa: E203
|
||||
if len(subpath) == 0:
|
||||
r = make_response(f"Redirect to {gateway_basepath}\n", 301)
|
||||
r.headers["location"] = gateway_basepath
|
||||
r = make_response(f"Redirect to {gateway_basepath}\n", 302)
|
||||
r.headers["location"] = gateway_basepath + querystring()
|
||||
return r
|
||||
|
||||
port = self.port
|
||||
cellxgene_basepath = f"http://127.0.0.1:{port}"
|
||||
elif self.status == CacheEntryStatus.loading:
|
||||
launch_time = datetime.datetime.fromtimestamp(self.launchtime)
|
||||
return render_template(
|
||||
"loading.html",
|
||||
launchtime=launch_time,
|
||||
all_output=self.all_output,
|
||||
)
|
||||
|
||||
headers = {}
|
||||
copy_headers = [
|
||||
"accept",
|
||||
"accept-encoding",
|
||||
"accept-language",
|
||||
"cache-control",
|
||||
"connection",
|
||||
"content-length",
|
||||
"content-type",
|
||||
"cookie",
|
||||
"host",
|
||||
"origin",
|
||||
"pragma",
|
||||
"referer",
|
||||
"sec-fetch-mode",
|
||||
"sec-fetch-site",
|
||||
"user-agent",
|
||||
]
|
||||
for h in copy_headers:
|
||||
if h in request.headers:
|
||||
headers[h] = request.headers[h]
|
||||
|
||||
if "accept" in request.headers:
|
||||
headers["accept"] = request.headers["accept"]
|
||||
if "user-agent" in request.headers:
|
||||
headers["user-agent"] = request.headers["user-agent"]
|
||||
if "content-type" in request.headers:
|
||||
headers["content-type"] = request.headers["content-type"]
|
||||
full_path = self.cellxgene_basepath() + subpath + querystring()
|
||||
|
||||
if request.method in ["GET", "HEAD", "OPTIONS"]:
|
||||
cellxgene_response = get(
|
||||
cellxgene_basepath + subpath, headers=headers
|
||||
)
|
||||
elif request.method == "PUT":
|
||||
cellxgene_response = put(
|
||||
cellxgene_basepath + subpath,
|
||||
headers=headers,
|
||||
data=request.data.decode(),
|
||||
)
|
||||
elif request.method == "POST":
|
||||
cellxgene_response = post(
|
||||
cellxgene_basepath + subpath,
|
||||
headers=headers,
|
||||
data=request.data.decode(),
|
||||
)
|
||||
else:
|
||||
raise CellxgeneException(
|
||||
f"Unexpected method {request.method}", 400
|
||||
)
|
||||
content_type = cellxgene_response.headers["content-type"]
|
||||
if "text" in content_type:
|
||||
cellxgene_content = cellxgene_response.content.decode()
|
||||
gateway_content = cellxgene_content.replace(
|
||||
"http://fonts.gstatic.com", "https://fonts.gstatic.com"
|
||||
).replace(cellxgene_basepath, gateway_basepath)
|
||||
else:
|
||||
gateway_content = cellxgene_response.content
|
||||
try:
|
||||
cellxgene_response = None
|
||||
if request.method in ["GET", "HEAD", "OPTIONS"]:
|
||||
cellxgene_response = get(full_path, headers=headers)
|
||||
elif request.method == "PUT":
|
||||
cellxgene_response = put(
|
||||
full_path,
|
||||
headers=headers,
|
||||
data=request.data,
|
||||
)
|
||||
elif request.method == "POST":
|
||||
cellxgene_response = post(
|
||||
full_path,
|
||||
headers=headers,
|
||||
data=request.data,
|
||||
)
|
||||
else:
|
||||
raise CellxgeneException(f"Unexpected method {request.method}", 400)
|
||||
content_type = cellxgene_response.headers["content-type"]
|
||||
if "text" in content_type:
|
||||
gateway_content = self.rewrite_text_content(
|
||||
cellxgene_response.content.decode()
|
||||
)
|
||||
else:
|
||||
gateway_content = cellxgene_response.content
|
||||
|
||||
gateway_response = make_response(
|
||||
gateway_content,
|
||||
cellxgene_response.status_code,
|
||||
{"Content-Type": content_type},
|
||||
)
|
||||
resp_headers = {}
|
||||
for h in copy_headers:
|
||||
if h in cellxgene_response.headers:
|
||||
resp_headers[h] = cellxgene_response.headers[h]
|
||||
|
||||
gateway_response = make_response(
|
||||
gateway_content,
|
||||
cellxgene_response.status_code,
|
||||
resp_headers,
|
||||
)
|
||||
finally:
|
||||
if cellxgene_response is not None:
|
||||
cellxgene_response.close()
|
||||
return gateway_response
|
||||
|
||||
81
cellxgene_gateway/cache_key.py
Normal file
81
cellxgene_gateway/cache_key.py
Normal file
@@ -0,0 +1,81 @@
|
||||
# Copyright 2019 Novartis Institutes for BioMedical Research Inc. Licensed
|
||||
# under the Apache License, Version 2.0 (the "License"); you may not use
|
||||
# this file except in compliance with the License. You may obtain a copy
|
||||
# of the License at http://www.apache.org/licenses/LICENSE-2.0. Unless
|
||||
# required by applicable law or agreed to in writing, software distributed
|
||||
# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES
|
||||
# OR CONDITIONS OF ANY KIND, either express or implied. See the License for
|
||||
# the specific language governing permissions and limitations under the License.
|
||||
|
||||
# There are three kinds of CacheKey:
|
||||
# 1) somedir/dataset.h5ad: a dataset
|
||||
# in this case, descriptor == dataset == 'somedir/dataset.h5ad'
|
||||
# 2) somedir/dataset_annotations/my_annotations.csv : an actual annotations file.
|
||||
# in this case, descriptor == 'somedir/dataset_annotations/my_annotations.csv', dataset == 'somedir/dataset.h5ad'
|
||||
# 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'
|
||||
|
||||
from cellxgene_gateway import flask_util
|
||||
from cellxgene_gateway.items.item import Item
|
||||
from cellxgene_gateway.items.item_source import ItemSource, LookupResult
|
||||
|
||||
|
||||
class CacheKey:
|
||||
@property
|
||||
def descriptor(self):
|
||||
if self.annotation_item is None:
|
||||
return self.h5ad_item.descriptor
|
||||
else:
|
||||
return self.annotation_item.descriptor
|
||||
|
||||
@property
|
||||
def file_path(self):
|
||||
return self.source.get_local_path(self.h5ad_item)
|
||||
|
||||
@property
|
||||
def annotation_file_path(self):
|
||||
if self.annotation_item is None:
|
||||
return None
|
||||
else:
|
||||
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
|
||||
def source_name(self):
|
||||
return self.source.name
|
||||
|
||||
@property
|
||||
def annotation_descriptor(self):
|
||||
if self.annotation_item is None:
|
||||
return None
|
||||
else:
|
||||
return self.annotation_item.descriptor
|
||||
|
||||
def equals(self, other):
|
||||
return (
|
||||
(self.source.name == other.source.name)
|
||||
and (self.h5ad_item.descriptor == other.h5ad_item.descriptor)
|
||||
and (self.annotation_descriptor == other.annotation_descriptor)
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self, h5ad_item: Item, source: ItemSource, annotation_item: Item = None
|
||||
):
|
||||
assert h5ad_item is not None
|
||||
assert source is not None
|
||||
self.h5ad_item = h5ad_item
|
||||
self.annotation_item = annotation_item
|
||||
self.source = source
|
||||
|
||||
@classmethod
|
||||
def for_lookup(cls, source: ItemSource, lookup: LookupResult):
|
||||
return CacheKey(lookup.h5ad_item, source, lookup.annotation_item)
|
||||
@@ -14,81 +14,18 @@ from flask_api import status
|
||||
from cellxgene_gateway import env
|
||||
from cellxgene_gateway.cellxgene_exception import CellxgeneException
|
||||
|
||||
|
||||
def is_subdir(full_path, parent_path):
|
||||
subdir = os.path.realpath(full_path)
|
||||
parent = os.path.realpath(parent_path)
|
||||
return subdir.startswith(parent)
|
||||
annotations_suffix = "_annotations"
|
||||
h5ad_suffix = ".h5ad"
|
||||
|
||||
|
||||
def create_dir(parent_path, dir_name):
|
||||
full_path = os.path.join(parent_path, dir_name)
|
||||
|
||||
if "/" in dir_name:
|
||||
raise CellxgeneException(
|
||||
"Please have no slashes in the intended directory.",
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
elif not os.path.exists(parent_path):
|
||||
raise CellxgeneException(
|
||||
"The selected User directory does not exist.",
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
elif os.path.exists(full_path):
|
||||
raise CellxgeneException(
|
||||
"The provided subdirectory already exists within Directory.",
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
elif not is_subdir(full_path, parent_path):
|
||||
raise CellxgeneException(
|
||||
"The directory must be a subdirectory of the parent path.",
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
elif not os.path.isdir(parent_path):
|
||||
raise CellxgeneException(
|
||||
"The parent is not a directory.", status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
else:
|
||||
os.mkdir(full_path)
|
||||
def make_h5ad(el):
|
||||
return el[: -len(annotations_suffix)] + h5ad_suffix
|
||||
|
||||
|
||||
def recurse_dir(path):
|
||||
if not os.path.exists(path):
|
||||
raise CellxgeneException(
|
||||
"The given path does not exist.", status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
def make_entry(el):
|
||||
full_path = os.path.join(path, el)
|
||||
if os.path.isfile(full_path):
|
||||
return {
|
||||
"path": full_path.replace(env.cellxgene_data, ""),
|
||||
"name": el,
|
||||
"type": "file",
|
||||
}
|
||||
elif os.path.isdir(full_path):
|
||||
return {
|
||||
"path": full_path,
|
||||
"name": el,
|
||||
"type": "directory",
|
||||
"children": recurse_dir(full_path),
|
||||
}
|
||||
else:
|
||||
raise CellxgeneException(
|
||||
"Given path is neither file nor directory.",
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
return [make_entry(x) for x in os.listdir(path)]
|
||||
def make_annotations(el):
|
||||
return el[:-5] + annotations_suffix
|
||||
|
||||
|
||||
def render_entries(entries):
|
||||
return "<ul>" + "\n".join([render_entry(e) for e in entries]) + "</ul>"
|
||||
|
||||
|
||||
def render_entry(entry):
|
||||
if entry["type"] == "file":
|
||||
url = 'view' + '/' + entry['path'].lstrip("/")
|
||||
return f"<li> <a href='{ url}'>{entry['name']}</a></li>"
|
||||
elif entry["type"] == "directory":
|
||||
return f"<li>{entry['name']}{render_entries(entry['children'])}</li>"
|
||||
def ensure_dir_exists(file_path):
|
||||
if not os.path.exists(file_path):
|
||||
os.makedirs(file_path)
|
||||
|
||||
@@ -7,32 +7,65 @@
|
||||
# OR CONDITIONS OF ANY KIND, either express or implied. See the License for
|
||||
# the specific language governing permissions and limitations under the License.
|
||||
|
||||
import os
|
||||
import logging
|
||||
import os
|
||||
|
||||
cellxgene_location = os.environ.get("CELLXGENE_LOCATION")
|
||||
cellxgene_data = os.environ.get("CELLXGENE_DATA")
|
||||
gateway_host = os.environ.get("GATEWAY_HOST")
|
||||
gateway_protocol = os.environ.get("GATEWAY_PROTOCOL")
|
||||
cellxgene_data = os.environ.get("CELLXGENE_DATA", "")
|
||||
cellxgene_args = os.environ.get("CELLXGENE_ARGS", None)
|
||||
gateway_port = int(os.environ.get("GATEWAY_PORT", "5005"))
|
||||
external_host = os.environ.get(
|
||||
"EXTERNAL_HOST",
|
||||
os.environ.get("GATEWAY_HOST", f"localhost:{gateway_port}"),
|
||||
)
|
||||
external_protocol = os.environ.get(
|
||||
"EXTERNAL_PROTOCOL", os.environ.get("GATEWAY_PROTOCOL", None)
|
||||
)
|
||||
ip = os.environ.get("GATEWAY_IP")
|
||||
extra_scripts = os.environ.get("GATEWAY_EXTRA_SCRIPTS")
|
||||
ttl = os.environ.get("GATEWAY_TTL")
|
||||
enable_upload = os.environ.get("GATEWAY_ENABLE_UPLOAD", "").lower() in ['true', '1']
|
||||
expire_seconds = int(
|
||||
os.environ.get("GATEWAY_EXPIRE_SECONDS", os.environ.get("GATEWAY_TTL", "3600"))
|
||||
)
|
||||
enable_annotations = os.environ.get("GATEWAY_ENABLE_ANNOTATIONS", "").lower() in [
|
||||
"true",
|
||||
"1",
|
||||
]
|
||||
enable_backed_mode = os.environ.get("GATEWAY_ENABLE_BACKED_MODE", "").lower() in [
|
||||
"true",
|
||||
"1",
|
||||
]
|
||||
log_level = logging.getLevelName(os.environ.get("GATEWAY_LOG_LEVEL", "INFO"))
|
||||
|
||||
env_vars = {
|
||||
"CELLXGENE_LOCATION": cellxgene_location,
|
||||
"CELLXGENE_DATA": cellxgene_data,
|
||||
"GATEWAY_HOST": gateway_host,
|
||||
"GATEWAY_PROTOCOL": gateway_protocol,
|
||||
"GATEWAY_IP": ip,
|
||||
}
|
||||
|
||||
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 = {
|
||||
"EXTERNAL_HOST": external_host,
|
||||
"EXTERNAL_PROTOCOL": external_protocol,
|
||||
"GATEWAY_IP": ip,
|
||||
"GATEWAY_PORT": gateway_port,
|
||||
"GATEWAY_EXTRA_SCRIPTS": extra_scripts,
|
||||
"GATEWAY_TTL": ttl,
|
||||
"GATEWAY_ENABLE_UPLOAD": enable_upload,
|
||||
"GATEWAY_EXPIRE_SECONDS": expire_seconds,
|
||||
"GATEWAY_ENABLE_ANNOTATIONS": enable_annotations,
|
||||
"GATEWAY_ENABLE_BACKED_MODE": enable_backed_mode,
|
||||
"GATEWAY_LOG_LEVEL": log_level,
|
||||
"CELLXGENE_ARGS": cellxgene_args,
|
||||
"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,
|
||||
}
|
||||
|
||||
|
||||
def validate():
|
||||
if not all(env_vars.values()):
|
||||
raise ValueError(
|
||||
@@ -47,11 +80,12 @@ def validate():
|
||||
|
||||
export CELLXGENE_LOCATION=~/anaconda/envs/cellxgene-dev/bin/cellxgene
|
||||
export CELLXGENE_DATA=../cellxgene_data
|
||||
export GATEWAY_HOST=localhost:5005
|
||||
export GATEWAY_PROTOCOL=http
|
||||
export GATEWAY_IP=127.0.0.1
|
||||
"""
|
||||
)
|
||||
else:
|
||||
logging.getLogger("cellxgene_gateway").info(f"Got required env: {env_vars}", )
|
||||
logging.getLogger("cellxgene_gateway").info(f"Got optional env: {optional_env_vars}")
|
||||
logging.getLogger("cellxgene_gateway").info(
|
||||
f"Got required env: {env_vars}",
|
||||
)
|
||||
logging.getLogger("cellxgene_gateway").info(
|
||||
f"Got optional env: {optional_env_vars}"
|
||||
)
|
||||
|
||||
@@ -7,13 +7,20 @@
|
||||
# OR CONDITIONS OF ANY KIND, either express or implied. See the License for
|
||||
# the specific language governing permissions and limitations under the License.
|
||||
|
||||
from cellxgene_gateway import env
|
||||
from json import loads
|
||||
from json.decoder import JSONDecodeError
|
||||
|
||||
from cellxgene_gateway import env
|
||||
|
||||
|
||||
def get_extra_scripts():
|
||||
# can be array of script tags to inject on every page, e.g. for google analytics could be
|
||||
# ['https://www.googletagmanager.com/gtag/js?id=UA-123456-2',
|
||||
# f"{env.gateway_protocol}://{env.gateway_host}/static/js/google_ua.js"]
|
||||
# f"{env.external_protocol}://{env.external_host}/static/js/google_ua.js"]
|
||||
# where google_ua.js is a script you add to the static/js folder prior to deployment.
|
||||
return [] if env.extra_scripts is None else loads(env.extra_scripts)
|
||||
try:
|
||||
return [] if env.extra_scripts is None else loads(env.extra_scripts)
|
||||
except JSONDecodeError as exc:
|
||||
raise Exception(
|
||||
f'Error parsing GATEWAY_EXTRA_SCRIPTS, expected JSON array e.g. ["https://example.com/path/to/script.js"]'
|
||||
) from exc
|
||||
|
||||
67
cellxgene_gateway/filecrawl.py
Normal file
67
cellxgene_gateway/filecrawl.py
Normal file
@@ -0,0 +1,67 @@
|
||||
# Copyright 2019 Novartis Institutes for BioMedical Research Inc. Licensed
|
||||
# under the Apache License, Version 2.0 (the "License"); you may not use
|
||||
# this file except in compliance with the License. You may obtain a copy
|
||||
# of the License at http://www.apache.org/licenses/LICENSE-2.0. Unless
|
||||
# required by applicable law or agreed to in writing, software distributed
|
||||
# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES
|
||||
# OR CONDITIONS OF ANY KIND, either express or implied. See the License for
|
||||
# the specific language governing permissions and limitations under the License.
|
||||
|
||||
import os
|
||||
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):
|
||||
url = flask_util.view_url(
|
||||
item_source.get_annotations_subpath(item), item_source.name
|
||||
)
|
||||
new_annotation = f"<a class='new' href='{url}'>new</a>"
|
||||
annotations = (
|
||||
", ".join(
|
||||
[
|
||||
f"<a href='{CacheKey(item, item_source, a).view_url}/'>{a.name}</a>"
|
||||
for a in item.annotations
|
||||
]
|
||||
)
|
||||
+ ", "
|
||||
if item.annotations
|
||||
else ""
|
||||
)
|
||||
return " | annotations: " + annotations + new_annotation
|
||||
|
||||
|
||||
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>"
|
||||
return item_string
|
||||
|
||||
|
||||
def render_item_tree(item_tree, item_source):
|
||||
items = (
|
||||
"\n".join([render_item(i, item_source) for i in item_tree.items])
|
||||
if item_tree.items
|
||||
else ""
|
||||
)
|
||||
branches = (
|
||||
"\n".join([render_item_tree(b, item_source) for b in item_tree.branches])
|
||||
if item_tree.branches
|
||||
else ""
|
||||
)
|
||||
html = "<ul>" + items + branches + "</ul>"
|
||||
if item_tree.descriptor:
|
||||
descriptor = item_tree.descriptor.lstrip("/")
|
||||
url = f"/filecrawl/{descriptor}?source={item_source.name}"
|
||||
name = descriptor.rsplit("/", 1)[-1]
|
||||
return f"<li><a href='{url}'>{name}</a>{html}</li>"
|
||||
else:
|
||||
return html
|
||||
|
||||
|
||||
def render_item_source(item_source, filter=None):
|
||||
item_tree = item_source.list_items(filter)
|
||||
filterpart = "" if filter is None else ":" + filter
|
||||
heading = f"<h6><a href='/filecrawl.html?source={urllib.parse.quote_plus(item_source.name)}'>{item_source.name}</a>{filterpart}</h6>"
|
||||
return heading + render_item_tree(item_tree, item_source)
|
||||
33
cellxgene_gateway/flask_util.py
Normal file
33
cellxgene_gateway/flask_util.py
Normal file
@@ -0,0 +1,33 @@
|
||||
# Copyright 2019 Novartis Institutes for BioMedical Research Inc. Licensed
|
||||
# under the Apache License, Version 2.0 (the "License"); you may not use
|
||||
# this file except in compliance with the License. You may obtain a copy
|
||||
# of the License at http://www.apache.org/licenses/LICENSE-2.0. Unless
|
||||
# required by applicable law or agreed to in writing, software distributed
|
||||
# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES
|
||||
# OR CONDITIONS OF ANY KIND, either express or implied. See the License for
|
||||
# the specific language governing permissions and limitations under the License.
|
||||
|
||||
from flask import request, url_for
|
||||
|
||||
|
||||
def querystring():
|
||||
qs = request.query_string.decode()
|
||||
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)
|
||||
@@ -6,38 +6,76 @@
|
||||
# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES
|
||||
# OR CONDITIONS OF ANY KIND, either express or implied. See the License for
|
||||
# the specific language governing permissions and limitations under the License.
|
||||
|
||||
# import BaseHTTPServer
|
||||
import datetime
|
||||
import os
|
||||
import logging
|
||||
from threading import Thread, Lock
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import urllib.parse
|
||||
from threading import Lock, Thread
|
||||
|
||||
from flask import (
|
||||
Flask,
|
||||
make_response,
|
||||
redirect,
|
||||
render_template,
|
||||
request,
|
||||
send_from_directory,
|
||||
url_for,
|
||||
)
|
||||
from flask_api import status
|
||||
from werkzeug import secure_filename
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
|
||||
from cellxgene_gateway import env
|
||||
from cellxgene_gateway import env, flask_util
|
||||
from cellxgene_gateway.backend_cache import BackendCache
|
||||
from cellxgene_gateway.cache_entry import CacheEntryStatus
|
||||
from cellxgene_gateway.cache_key import CacheKey
|
||||
from cellxgene_gateway.cellxgene_exception import CellxgeneException
|
||||
from cellxgene_gateway.dir_util import create_dir, recurse_dir, render_entries, is_subdir
|
||||
from cellxgene_gateway.extra_scripts import get_extra_scripts
|
||||
from cellxgene_gateway.path_util import get_dataset, get_file_path
|
||||
from cellxgene_gateway.filecrawl import render_item_source
|
||||
from cellxgene_gateway.process_exception import ProcessException
|
||||
from cellxgene_gateway.prune_process_cache import PruneProcessCache
|
||||
from cellxgene_gateway.util import current_time_stamp
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
item_sources = []
|
||||
default_item_source = None
|
||||
|
||||
|
||||
def _force_https(app):
|
||||
def wrapper(environ, start_response):
|
||||
if env.external_protocol is not None:
|
||||
environ["wsgi.url_scheme"] = env.external_protocol
|
||||
return app(environ, start_response)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def set_no_cache(resp):
|
||||
resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
||||
resp.headers["Pragma"] = "no-cache"
|
||||
resp.headers["Expires"] = "0"
|
||||
resp.headers["Cache-Control"] = "public, max-age=0"
|
||||
return resp
|
||||
|
||||
|
||||
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()
|
||||
location = f"{env.gateway_protocol}://{env.gateway_host}"
|
||||
|
||||
|
||||
@app.errorhandler(CellxgeneException)
|
||||
@@ -73,7 +111,8 @@ def handle_invalid_process(error):
|
||||
http_status=error.http_status,
|
||||
stdout=error.stdout,
|
||||
stderr=error.stderr,
|
||||
dataset=error.dataset,
|
||||
relaunch_url=error.key.relaunch_url(),
|
||||
annotation_file=error.key.annotation_descriptor,
|
||||
),
|
||||
error.http_status,
|
||||
)
|
||||
@@ -90,149 +129,197 @@ def favicon():
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
users = [
|
||||
name
|
||||
for name in os.listdir(env.cellxgene_data)
|
||||
if os.path.isdir(os.path.join(env.cellxgene_data, name))
|
||||
]
|
||||
return render_template(
|
||||
"index.html",
|
||||
ip=env.ip,
|
||||
cellxgene_data=env.cellxgene_data,
|
||||
extra_scripts=get_extra_scripts(),
|
||||
users=users,
|
||||
enable_upload=env.enable_upload,
|
||||
)
|
||||
|
||||
def make_user():
|
||||
dir_name = request.form["directory"]
|
||||
|
||||
create_dir(env.cellxgene_data, dir_name)
|
||||
|
||||
return redirect(location, code=302)
|
||||
|
||||
|
||||
def make_subdir():
|
||||
parent_path = os.path.join(env.cellxgene_data, request.form["usernames"])
|
||||
dir_name = request.form["directory"]
|
||||
|
||||
create_dir(parent_path, dir_name)
|
||||
|
||||
return redirect(location, code=302)
|
||||
|
||||
|
||||
def upload_file():
|
||||
upload_dir = request.form["path"]
|
||||
|
||||
full_upload_path = os.path.join(env.cellxgene_data, upload_dir)
|
||||
if is_subdir(full_upload_path, env.cellxgene_data) and os.path.isdir(full_upload_path):
|
||||
if request.method == "POST":
|
||||
if "file" in request.files:
|
||||
f = request.files["file"]
|
||||
if f and f.filename.endswith(".h5ad"):
|
||||
f.save(
|
||||
os.path.join(full_upload_path, secure_filename(f.filename))
|
||||
)
|
||||
return redirect("/filecrawl.html", code=302)
|
||||
else:
|
||||
raise CellxgeneException(
|
||||
"Uploaded file must be in anndata (.h5ad) format.",
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
else:
|
||||
raise CellxgeneException(
|
||||
"A file must be chosen to upload.",
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
else:
|
||||
raise CellxgeneException(
|
||||
"Invalid directory.", status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
return redirect(env.location, code=302)
|
||||
|
||||
|
||||
if env.enable_upload:
|
||||
app.add_url_rule('/make_user', 'make_user', make_user, methods=["POST"])
|
||||
app.add_url_rule('/make_subdir', 'make_subdir', make_subdir, methods=["POST"])
|
||||
app.add_url_rule('/upload_file', 'upload_file', upload_file, methods=["POST"])
|
||||
|
||||
@app.route("/filecrawl.html")
|
||||
def filecrawl():
|
||||
|
||||
entries = recurse_dir(env.cellxgene_data)
|
||||
rendered_html = render_entries(entries)
|
||||
return render_template(
|
||||
"filecrawl.html",
|
||||
extra_scripts=get_extra_scripts(),
|
||||
rendered_html=rendered_html,
|
||||
@app.route("/filecrawl/<path:path>")
|
||||
def filecrawl(path=None):
|
||||
source_name = request.args.get("source")
|
||||
sources = (
|
||||
filter(
|
||||
lambda x: x.name == urllib.parse.unquote_plus(source_name),
|
||||
item_sources,
|
||||
)
|
||||
if source_name
|
||||
else item_sources
|
||||
)
|
||||
# loop all data sources --
|
||||
rendered_sources = [
|
||||
render_item_source(item_source, path) for item_source in sources
|
||||
] # will we need to make this async in the page???
|
||||
rendered_html = "\n".join(rendered_sources)
|
||||
|
||||
resp = make_response(
|
||||
render_template(
|
||||
"filecrawl.html",
|
||||
extra_scripts=get_extra_scripts(),
|
||||
rendered_html=rendered_html,
|
||||
path=path,
|
||||
)
|
||||
)
|
||||
set_no_cache(resp)
|
||||
return resp
|
||||
|
||||
|
||||
entry_lock = Lock()
|
||||
|
||||
|
||||
def matching_source(source_name):
|
||||
if source_name is None:
|
||||
source_name = default_item_source.name
|
||||
matching = [i for i in item_sources if i.name == source_name]
|
||||
if len(matching) != 1:
|
||||
raise Exception(f"Could not find matching item source {source_name}")
|
||||
source = matching[0]
|
||||
return source
|
||||
|
||||
|
||||
@app.route(
|
||||
"/source/<path:source_name>/view/<path:path>",
|
||||
methods=["GET", "PUT", "POST"],
|
||||
)
|
||||
@app.route("/view/<path:path>", methods=["GET", "PUT", "POST"])
|
||||
def do_view(path):
|
||||
dataset = get_dataset(path)
|
||||
file_path = get_file_path(dataset)
|
||||
with entry_lock:
|
||||
match = cache.check_entry(dataset)
|
||||
if match is None:
|
||||
uascripts = get_extra_scripts()
|
||||
match = cache.create_entry(dataset, file_path, uascripts)
|
||||
def do_view(path, source_name=None):
|
||||
source = matching_source(source_name)
|
||||
match = cache.check_path(source, path)
|
||||
|
||||
if match is None:
|
||||
lookup = source.lookup(path)
|
||||
if lookup is None:
|
||||
raise CellxgeneException(
|
||||
f"Could not find item for path {path} in source {source.name}",
|
||||
404,
|
||||
)
|
||||
key = CacheKey.for_lookup(source, lookup)
|
||||
print(
|
||||
f"view path={path}, source_name={source_name}, dataset={key.file_path}, annotation_file= {key.annotation_file_path}, key={key.descriptor}, source={key.source_name}"
|
||||
)
|
||||
with entry_lock:
|
||||
match = cache.check_entry(key)
|
||||
if match is None:
|
||||
uascripts = get_extra_scripts()
|
||||
match = cache.create_entry(key, uascripts)
|
||||
|
||||
match.timestamp = current_time_stamp()
|
||||
|
||||
if match.status == "loaded":
|
||||
return match.serve_content(path)
|
||||
elif match.status == "loading":
|
||||
launch_time = datetime.datetime.fromtimestamp(match.launchtime)
|
||||
return render_template(
|
||||
"loading.html", launchtime=launch_time, all_output=match.all_output
|
||||
)
|
||||
elif match.status == "error":
|
||||
if (
|
||||
match.status == CacheEntryStatus.loaded
|
||||
or match.status == CacheEntryStatus.loading
|
||||
):
|
||||
if source.is_authorized(match.key.descriptor):
|
||||
return match.serve_content(path)
|
||||
else:
|
||||
raise CellxgeneException("User not authorized to access this data", 403)
|
||||
elif match.status == CacheEntryStatus.error:
|
||||
raise ProcessException.from_cache_entry(match)
|
||||
|
||||
|
||||
@app.route("/cache_status", methods=["GET"])
|
||||
def do_GET_status():
|
||||
return render_template("cache_status.html", entry_list=cache.entry_list)
|
||||
return render_template(
|
||||
"cache_status.html",
|
||||
entry_list=cache.entry_list,
|
||||
extra_scripts=get_extra_scripts(),
|
||||
)
|
||||
|
||||
|
||||
@app.route("/cache_status.json", methods=["GET"])
|
||||
def do_GET_status_json():
|
||||
return json.dumps({'launchtime':app.launchtime,
|
||||
'entry_list':[{
|
||||
'dataset': entry.dataset,
|
||||
'launchtime': entry.launchtime,
|
||||
'last_access': entry.timestamp,
|
||||
'status': entry.status
|
||||
} for entry in cache.entry_list]})
|
||||
return json.dumps(
|
||||
{
|
||||
"launchtime": app.launchtime,
|
||||
"entry_list": [
|
||||
{
|
||||
"dataset": entry.key.dataset,
|
||||
"annotation_file": entry.key.annotation_file,
|
||||
"launchtime": entry.launchtime,
|
||||
"last_access": entry.timestamp,
|
||||
"status": entry.status,
|
||||
}
|
||||
for entry in cache.entry_list
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.route("/relaunch/<path:path>", methods=["GET"])
|
||||
def do_relaunch(path):
|
||||
dataset = get_dataset(path)
|
||||
match = cache.check_entry(dataset)
|
||||
source_name = request.args.get("source_name") or default_item_source.name
|
||||
source = matching_source(source_name)
|
||||
key = CacheKey.for_lookup(source, source.lookup(path))
|
||||
match = cache.check_entry(key)
|
||||
if not match is None:
|
||||
match.terminate()
|
||||
return redirect(url_for("do_view", path=path), code=302)
|
||||
return redirect(
|
||||
key.view_url,
|
||||
code=302,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/terminate/<path:path>", methods=["GET"])
|
||||
def do_terminate(path):
|
||||
dataset = get_dataset(path)
|
||||
match = cache.check_entry(dataset)
|
||||
source_name = request.args.get("source_name") or default_item_source.name
|
||||
source = matching_source(source_name)
|
||||
key = CacheKey.for_lookup(source, source.lookup(path))
|
||||
match = cache.check_entry(key)
|
||||
if not match is None:
|
||||
match.terminate()
|
||||
return redirect(url_for("do_GET_status"), code=302)
|
||||
|
||||
|
||||
def main():
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s:%(name)s:%(levelname)s:%(message)s')
|
||||
@app.route("/metadata/ip_address", methods=["GET"])
|
||||
def ip_address():
|
||||
resp = make_response(env.ip)
|
||||
return set_no_cache(resp)
|
||||
|
||||
|
||||
def launch():
|
||||
env.validate()
|
||||
if not item_sources or not len(item_sources):
|
||||
raise Exception("No data sources specified for Cellxgene Gateway")
|
||||
|
||||
global default_item_source
|
||||
if default_item_source is None:
|
||||
default_item_source = item_sources[0]
|
||||
|
||||
pruner = PruneProcessCache(cache)
|
||||
|
||||
background_thread = Thread(target=pruner)
|
||||
background_thread.start()
|
||||
|
||||
app.launchtime = current_time_stamp()
|
||||
app.run(host="0.0.0.0", port=5005, debug=False)
|
||||
app.run(host="0.0.0.0", port=env.gateway_port, debug=False)
|
||||
|
||||
|
||||
def main():
|
||||
logging.basicConfig(
|
||||
level=env.log_level,
|
||||
format="%(asctime)s:%(name)s:%(levelname)s:%(message)s",
|
||||
)
|
||||
cellxgene_data = os.environ.get("CELLXGENE_DATA", None)
|
||||
cellxgene_bucket = os.environ.get("CELLXGENE_BUCKET", None)
|
||||
|
||||
if cellxgene_bucket is not None:
|
||||
from cellxgene_gateway.items.s3.s3item_source import S3ItemSource
|
||||
|
||||
item_sources.append(S3ItemSource(cellxgene_bucket, name="s3"))
|
||||
default_item_source = "s3"
|
||||
if cellxgene_data is not None:
|
||||
from cellxgene_gateway.items.file.fileitem_source import FileItemSource
|
||||
|
||||
item_sources.append(FileItemSource(cellxgene_data, name="local"))
|
||||
default_item_source = "local"
|
||||
if len(item_sources) == 0:
|
||||
raise Exception("Please specify CELLXGENE_DATA or CELLXGENE_BUCKET")
|
||||
flask_util.include_source_in_url = len(item_sources) > 1
|
||||
|
||||
launch()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
0
cellxgene_gateway/items/__init__.py
Normal file
0
cellxgene_gateway/items/__init__.py
Normal file
0
cellxgene_gateway/items/file/__init__.py
Normal file
0
cellxgene_gateway/items/file/__init__.py
Normal file
28
cellxgene_gateway/items/file/fileitem.py
Normal file
28
cellxgene_gateway/items/file/fileitem.py
Normal file
@@ -0,0 +1,28 @@
|
||||
# Copyright 2019 Novartis Institutes for BioMedical Research Inc. Licensed
|
||||
# under the Apache License, Version 2.0 (the "License"); you may not use
|
||||
# this file except in compliance with the License. You may obtain a copy
|
||||
# of the License at http://www.apache.org/licenses/LICENSE-2.0. Unless
|
||||
# required by applicable law or agreed to in writing, software distributed
|
||||
# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES
|
||||
# OR CONDITIONS OF ANY KIND, either express or implied. See the License for
|
||||
# the specific language governing permissions and limitations under the License.
|
||||
|
||||
import os
|
||||
|
||||
from cellxgene_gateway.items.item import Item
|
||||
|
||||
|
||||
class FileItem(Item):
|
||||
"""e.g. FileItem(subpath = subpath, name = filename, type = ItemType.h5ad)
|
||||
|
||||
The Item superclass expects a 'name' and 'type'.
|
||||
"""
|
||||
|
||||
def __init__(self, subpath: str, ext: str = "", *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.subpath = subpath
|
||||
self.ext = ext
|
||||
|
||||
@property
|
||||
def descriptor(self) -> str:
|
||||
return os.path.join(self.subpath, self.name + self.ext).strip("/")
|
||||
186
cellxgene_gateway/items/file/fileitem_source.py
Normal file
186
cellxgene_gateway/items/file/fileitem_source.py
Normal file
@@ -0,0 +1,186 @@
|
||||
# Copyright 2019 Novartis Institutes for BioMedical Research Inc. Licensed
|
||||
# under the Apache License, Version 2.0 (the "License"); you may not use
|
||||
# this file except in compliance with the License. You may obtain a copy
|
||||
# of the License at http://www.apache.org/licenses/LICENSE-2.0. Unless
|
||||
# required by applicable law or agreed to in writing, software distributed
|
||||
# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES
|
||||
# OR CONDITIONS OF ANY KIND, either express or implied. See the License for
|
||||
# the specific language governing permissions and limitations under the License.
|
||||
|
||||
import os
|
||||
from typing import List
|
||||
|
||||
from cellxgene_gateway import dir_util
|
||||
from cellxgene_gateway.items.file.fileitem import FileItem
|
||||
from cellxgene_gateway.items.item import ItemTree, ItemType
|
||||
from cellxgene_gateway.items.item_source import ItemSource, LookupResult
|
||||
|
||||
|
||||
class FileItemSource(ItemSource):
|
||||
def __init__(
|
||||
self,
|
||||
base_path,
|
||||
name=None,
|
||||
h5ad_suffix=dir_util.h5ad_suffix,
|
||||
annotation_dir_suffix=dir_util.annotations_suffix,
|
||||
annotation_file_suffix=".csv",
|
||||
):
|
||||
self._name = name
|
||||
self.base_path = base_path
|
||||
self.h5ad_suffix = h5ad_suffix
|
||||
self.annotation_dir_suffix = annotation_dir_suffix
|
||||
self.annotation_file_suffix = annotation_file_suffix
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return self._name or f"Files:{self.base_path}"
|
||||
|
||||
def is_h5ad_file(self, path: str) -> bool:
|
||||
return path.endswith(self.h5ad_suffix) and os.path.isfile(path)
|
||||
|
||||
def convert_annotation_path_to_h5ad(self, path):
|
||||
return path[: -len(self.annotation_dir_suffix)] + self.h5ad_suffix
|
||||
|
||||
def convert_h5ad_path_to_annotation(self, path):
|
||||
return path[: -len(self.h5ad_suffix)] + self.annotation_dir_suffix
|
||||
|
||||
def get_local_path(self, item: FileItem) -> str:
|
||||
return os.path.join(self.base_path, item.descriptor)
|
||||
|
||||
def get_annotations_subpath(self, item) -> str:
|
||||
return self.convert_h5ad_path_to_annotation(item.descriptor)
|
||||
|
||||
def list_items(self, filter: str = None) -> ItemTree:
|
||||
item_tree = self.scan_directory("" if filter is None else filter)
|
||||
|
||||
"""def get_items(dir):
|
||||
if dir.branches:
|
||||
return [*dir.items, *[item for subdir in dir.branches for item in get_items(subdir)]]
|
||||
else:
|
||||
return dir.items
|
||||
|
||||
return get_items(self.item_tree)"""
|
||||
|
||||
return item_tree
|
||||
|
||||
def scan_directory(self, subpath="") -> dict:
|
||||
base_path = os.path.join(self.base_path, subpath)
|
||||
|
||||
if not os.path.exists(base_path):
|
||||
raise Exception(f"Path for local files '{base_path}' does not exist.")
|
||||
|
||||
filepath_map = dict(
|
||||
(filepath, os.path.join(base_path, filepath))
|
||||
for filepath in sorted(os.listdir(base_path))
|
||||
)
|
||||
|
||||
def is_annotation_dir(dir):
|
||||
return (
|
||||
dir.endswith(self.annotation_dir_suffix)
|
||||
and self.convert_annotation_path_to_h5ad(dir) in h5ad_paths
|
||||
)
|
||||
|
||||
h5ad_paths = [
|
||||
filepath
|
||||
for filepath, full_path in filepath_map.items()
|
||||
if self.is_h5ad_file(full_path)
|
||||
]
|
||||
|
||||
subdirs = [
|
||||
filepath
|
||||
for filepath, full_path in filepath_map.items()
|
||||
if os.path.isdir(full_path) and not is_annotation_dir(filepath)
|
||||
]
|
||||
|
||||
items = [
|
||||
self.make_fileitem_from_path(filename, subpath) for filename in h5ad_paths
|
||||
]
|
||||
branches = None
|
||||
if len(subdirs) > 0:
|
||||
branches = [
|
||||
self.scan_directory(os.path.join(subpath, subdir)) for subdir in subdirs
|
||||
]
|
||||
|
||||
return ItemTree(subpath, items, branches)
|
||||
|
||||
def create_annotation(self, item: FileItem, name: str) -> FileItem:
|
||||
annotation = self.make_fileitem_from_path(
|
||||
name, self.get_annotations_subpath(item), is_annotation=True
|
||||
)
|
||||
item.annotations = (item.annotations or []).append(annotation)
|
||||
return annotation
|
||||
|
||||
def update(self, item: FileItem) -> None:
|
||||
pass
|
||||
|
||||
def full_path(self, p):
|
||||
return os.path.join(self.base_path, p)
|
||||
|
||||
def lookup_item(self, descriptor):
|
||||
full_path = self.full_path(descriptor)
|
||||
if self.is_h5ad_file(full_path):
|
||||
return self.shallowitem_from_descriptor(descriptor)
|
||||
|
||||
def is_authorized(self, descriptor):
|
||||
return True
|
||||
|
||||
def lookup(self, indescriptor: str) -> LookupResult:
|
||||
descriptor = indescriptor.strip("/")
|
||||
if descriptor.endswith(self.annotation_file_suffix):
|
||||
annotation_item = self.shallowitem_from_descriptor(descriptor, True)
|
||||
h5ad_descriptor = self.convert_annotation_path_to_h5ad(
|
||||
annotation_item.subpath
|
||||
)
|
||||
item = self.lookup_item(h5ad_descriptor)
|
||||
if item is not None:
|
||||
dir_util.ensure_dir_exists(self.full_path(annotation_item.subpath))
|
||||
return LookupResult(item, annotation_item)
|
||||
else:
|
||||
item = self.lookup_item(descriptor)
|
||||
if item is not None:
|
||||
return LookupResult(item)
|
||||
|
||||
def shallowitem_from_descriptor(self, descriptor, is_annotation=False):
|
||||
filename = os.path.basename(descriptor)
|
||||
subpath = os.path.dirname(descriptor)
|
||||
return self.make_fileitem_from_path(
|
||||
filename,
|
||||
subpath,
|
||||
is_annotation,
|
||||
True,
|
||||
)
|
||||
|
||||
def make_fileitem_from_path(
|
||||
self, filename, subpath, is_annotation=False, is_shallow=False
|
||||
) -> 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(
|
||||
subpath=subpath,
|
||||
name=name,
|
||||
ext=ext,
|
||||
type=ItemType.annotation if is_annotation else ItemType.h5ad,
|
||||
)
|
||||
|
||||
if not is_annotation and not is_shallow:
|
||||
annotations = self.make_annotations_for_fileitem(item)
|
||||
item.annotations = annotations
|
||||
|
||||
return item
|
||||
|
||||
def make_annotations_for_fileitem(self, item: FileItem) -> List[FileItem]:
|
||||
annotations_subpath = self.get_annotations_subpath(item)
|
||||
annotations_fullpath = self.full_path(annotations_subpath)
|
||||
if os.path.isdir(annotations_fullpath):
|
||||
return [
|
||||
self.make_fileitem_from_path(annotation, annotations_subpath, True)
|
||||
for annotation in sorted(os.listdir(annotations_fullpath))
|
||||
if annotation.endswith(self.annotation_file_suffix)
|
||||
and os.path.isfile(os.path.join(annotations_fullpath, annotation))
|
||||
]
|
||||
else:
|
||||
return None
|
||||
41
cellxgene_gateway/items/item.py
Normal file
41
cellxgene_gateway/items/item.py
Normal file
@@ -0,0 +1,41 @@
|
||||
# Copyright 2019 Novartis Institutes for BioMedical Research Inc. Licensed
|
||||
# under the Apache License, Version 2.0 (the "License"); you may not use
|
||||
# this file except in compliance with the License. You may obtain a copy
|
||||
# of the License at http://www.apache.org/licenses/LICENSE-2.0. Unless
|
||||
# required by applicable law or agreed to in writing, software distributed
|
||||
# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES
|
||||
# OR CONDITIONS OF ANY KIND, either express or implied. See the License for
|
||||
# the specific language governing permissions and limitations under the License.
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from enum import Enum
|
||||
from typing import List
|
||||
|
||||
|
||||
class ItemType(Enum):
|
||||
annotation = "annotation"
|
||||
h5ad = "h5ad"
|
||||
|
||||
|
||||
class Item(ABC):
|
||||
def __init__(self, name: str, type: ItemType, annotations: List["Item"] = None):
|
||||
self.name = name
|
||||
self.type = type
|
||||
self.annotations = annotations
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def descriptor(self):
|
||||
raise Exception('"descriptor" not implemented')
|
||||
|
||||
|
||||
class ItemTree:
|
||||
def __init__(
|
||||
self,
|
||||
descriptor: str,
|
||||
items: List[Item] = None,
|
||||
branches: List["ItemTree"] = None,
|
||||
):
|
||||
self.descriptor = descriptor
|
||||
self.items = items
|
||||
self.branches = branches
|
||||
54
cellxgene_gateway/items/item_source.py
Normal file
54
cellxgene_gateway/items/item_source.py
Normal file
@@ -0,0 +1,54 @@
|
||||
# Copyright 2019 Novartis Institutes for BioMedical Research Inc. Licensed
|
||||
# under the Apache License, Version 2.0 (the "License"); you may not use
|
||||
# this file except in compliance with the License. You may obtain a copy
|
||||
# of the License at http://www.apache.org/licenses/LICENSE-2.0. Unless
|
||||
# required by applicable law or agreed to in writing, software distributed
|
||||
# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES
|
||||
# OR CONDITIONS OF ANY KIND, either express or implied. See the License for
|
||||
# the specific language governing permissions and limitations under the License.
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import List
|
||||
|
||||
from cellxgene_gateway.items.item import Item
|
||||
|
||||
|
||||
class LookupResult:
|
||||
def __init__(self, h5ad_item: Item, annotation_item: Item = None):
|
||||
self.h5ad_item = h5ad_item
|
||||
self.annotation_item = annotation_item
|
||||
|
||||
|
||||
class ItemSource(ABC):
|
||||
@abstractmethod
|
||||
def list_items(self, filter: str = None) -> List[Item]:
|
||||
raise Exception('"list_items" unimplemented')
|
||||
|
||||
@abstractmethod
|
||||
def get_local_path(self, item: Item) -> str:
|
||||
raise Exception('"local_path" unimplemented')
|
||||
|
||||
@abstractmethod
|
||||
def get_annotations_subpath(self, item) -> str:
|
||||
raise Exception('"annotations_path" unimplemented')
|
||||
|
||||
@abstractmethod
|
||||
def create_annotation(self, item: Item, name: str) -> Item:
|
||||
raise Exception('"annotation" unimplemented')
|
||||
|
||||
@abstractmethod
|
||||
def update(self, item: Item) -> None:
|
||||
raise Exception('"update" unimplemented')
|
||||
|
||||
@abstractmethod
|
||||
def is_authorized(self, descriptor: str) -> bool:
|
||||
raise Exception('"is_authorized" unimplemented')
|
||||
|
||||
@abstractmethod
|
||||
def lookup(self, descriptor: str) -> LookupResult:
|
||||
raise Exception('"lookup" unimplemented')
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def name(self):
|
||||
pass
|
||||
0
cellxgene_gateway/items/s3/__init__.py
Normal file
0
cellxgene_gateway/items/s3/__init__.py
Normal file
27
cellxgene_gateway/items/s3/s3item.py
Normal file
27
cellxgene_gateway/items/s3/s3item.py
Normal file
@@ -0,0 +1,27 @@
|
||||
# Copyright 2019 Novartis Institutes for BioMedical Research Inc. Licensed
|
||||
# under the Apache License, Version 2.0 (the "License"); you may not use
|
||||
# this file except in compliance with the License. You may obtain a copy
|
||||
# of the License at http://www.apache.org/licenses/LICENSE-2.0. Unless
|
||||
# required by applicable law or agreed to in writing, software distributed
|
||||
# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES
|
||||
# OR CONDITIONS OF ANY KIND, either express or implied. See the License for
|
||||
# the specific language governing permissions and limitations under the License.
|
||||
|
||||
import os
|
||||
|
||||
from cellxgene_gateway.items.item import Item
|
||||
|
||||
|
||||
class S3Item(Item):
|
||||
"""e.g. FileItem(subpath = subpath, name = filename, type = ItemType.h5ad)
|
||||
|
||||
The Item superclass expects a 'name' and 'type'.
|
||||
"""
|
||||
|
||||
def __init__(self, s3key: str, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.s3key = s3key
|
||||
|
||||
@property
|
||||
def descriptor(self) -> str:
|
||||
return self.s3key
|
||||
192
cellxgene_gateway/items/s3/s3item_source.py
Normal file
192
cellxgene_gateway/items/s3/s3item_source.py
Normal file
@@ -0,0 +1,192 @@
|
||||
# Copyright 2019 Novartis Institutes for BioMedical Research Inc. Licensed
|
||||
# under the Apache License, Version 2.0 (the "License"); you may not use
|
||||
# this file except in compliance with the License. You may obtain a copy
|
||||
# of the License at http://www.apache.org/licenses/LICENSE-2.0. Unless
|
||||
# required by applicable law or agreed to in writing, software distributed
|
||||
# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES
|
||||
# OR CONDITIONS OF ANY KIND, either express or implied. See the License for
|
||||
# the specific language governing permissions and limitations under the License.
|
||||
|
||||
import os
|
||||
from os.path import basename, dirname
|
||||
from typing import List
|
||||
|
||||
import flask
|
||||
import s3fs
|
||||
|
||||
from cellxgene_gateway import dir_util
|
||||
from cellxgene_gateway.items.item import ItemTree, ItemType
|
||||
from cellxgene_gateway.items.item_source import ItemSource, LookupResult
|
||||
from cellxgene_gateway.items.s3.s3item import S3Item
|
||||
|
||||
|
||||
def truthy(val: str):
|
||||
return val.lower() in ["true", "1"]
|
||||
|
||||
|
||||
class S3ItemSource(ItemSource):
|
||||
def __init__(
|
||||
self,
|
||||
bucket,
|
||||
name=None,
|
||||
h5ad_suffix=dir_util.h5ad_suffix,
|
||||
annotation_dir_suffix=dir_util.annotations_suffix,
|
||||
annotation_file_suffix=".csv",
|
||||
):
|
||||
self._name = name
|
||||
enable_cache = os.environ.get("S3_ENABLE_LISTINGS_CACHE", "false").lower()
|
||||
assert enable_cache in ["0", "1", "false", "true"]
|
||||
self.use_listings_cache = truthy(enable_cache)
|
||||
self.s3 = s3fs.S3FileSystem(use_listings_cache=self.use_listings_cache)
|
||||
if bucket.startswith("s3://"):
|
||||
raise Exception(
|
||||
f"Bucket name should not include s3:// prefix, got {bucket}"
|
||||
)
|
||||
self.bucket = bucket
|
||||
self.h5ad_suffix = h5ad_suffix
|
||||
self.annotation_dir_suffix = annotation_dir_suffix
|
||||
self.annotation_file_suffix = annotation_file_suffix
|
||||
|
||||
def url(self, key):
|
||||
return "s3://" + self.bucket + "/" + key
|
||||
|
||||
def remove_bucket(self, filepath):
|
||||
return filepath[len(self.bucket) :].lstrip("/")
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return self._name or f"Items:{self.url('')}"
|
||||
|
||||
def is_h5ad_url(self, s3url: str) -> bool:
|
||||
return s3url.endswith(self.h5ad_suffix) and self.s3.exists(s3url)
|
||||
|
||||
def convert_annotation_key_to_h5ad(self, s3key):
|
||||
return s3key[: -len(self.annotation_dir_suffix)] + self.h5ad_suffix
|
||||
|
||||
def convert_h5ad_key_to_annotation(self, s3key):
|
||||
return s3key[: -len(self.h5ad_suffix)] + self.annotation_dir_suffix
|
||||
|
||||
def get_local_path(self, item: S3Item) -> str:
|
||||
return self.url(item.descriptor)
|
||||
|
||||
def get_annotations_subpath(self, item) -> str:
|
||||
return self.convert_h5ad_key_to_annotation(item.descriptor)
|
||||
|
||||
def list_items(self, filter: str = None) -> ItemTree:
|
||||
item_tree = self.scan_directory("" if filter is None else filter)
|
||||
return item_tree
|
||||
|
||||
@property
|
||||
def refresh(self):
|
||||
return (
|
||||
truthy(flask.request.args.get("refresh", default="false"))
|
||||
or not self.use_listings_cache
|
||||
)
|
||||
|
||||
def scan_directory(self, directory_key="") -> dict:
|
||||
url = self.url(directory_key)
|
||||
|
||||
if not self.s3.exists(url):
|
||||
raise Exception(f"S3 url '{url}' does not exist.")
|
||||
|
||||
s3key_map = dict(
|
||||
(self.remove_bucket(filepath), "s3://" + filepath)
|
||||
for filepath in sorted(self.s3.ls(url, refresh=self.refresh))
|
||||
)
|
||||
|
||||
def is_annotation_dir(dir_s3key):
|
||||
return (
|
||||
dir_s3key.endswith(self.annotation_dir_suffix)
|
||||
and self.convert_annotation_key_to_h5ad(dir_s3key) in h5ad_keys
|
||||
)
|
||||
|
||||
h5ad_keys = [
|
||||
filepath
|
||||
for filepath, item_url in s3key_map.items()
|
||||
if self.is_h5ad_url(item_url)
|
||||
]
|
||||
|
||||
subdir_keys = [
|
||||
filepath
|
||||
for filepath, item_url in s3key_map.items()
|
||||
if self.s3.isdir(item_url) and not is_annotation_dir(filepath)
|
||||
]
|
||||
|
||||
items = [self.make_s3item_from_key(basename(key), key) for key in h5ad_keys]
|
||||
branches = None
|
||||
if len(subdir_keys) > 0:
|
||||
branches = [self.scan_directory(key) for key in subdir_keys]
|
||||
|
||||
return ItemTree(directory_key, items, branches)
|
||||
|
||||
def create_annotation(self, item: S3Item, name: str) -> S3Item:
|
||||
annotation = self.make_s3item_from_key(
|
||||
name, self.get_annotations_subpath(item), is_annotation=True
|
||||
)
|
||||
item.annotations = (item.annotations or []).append(annotation)
|
||||
return annotation
|
||||
|
||||
def update(self, item: S3Item) -> None:
|
||||
pass
|
||||
|
||||
def is_authorized(self, descriptor):
|
||||
return True
|
||||
|
||||
def lookup_item(self, descriptor):
|
||||
full_path = self.url(descriptor)
|
||||
if self.is_h5ad_url(full_path):
|
||||
return self.shallowitem_from_descriptor(descriptor)
|
||||
|
||||
def lookup(self, indescriptor: str) -> LookupResult:
|
||||
descriptor = indescriptor.strip("/")
|
||||
if descriptor.endswith(self.annotation_file_suffix):
|
||||
annotation_item = self.shallowitem_from_descriptor(descriptor, True)
|
||||
if not self.s3.exists(self.url(annotation_item.s3key)):
|
||||
with self.s3.open(self.url(annotation_item.s3key), "w") as f:
|
||||
f.write("")
|
||||
h5ad_descriptor = self.convert_annotation_key_to_h5ad(
|
||||
dirname(annotation_item.s3key)
|
||||
)
|
||||
item = self.shallowitem_from_descriptor(h5ad_descriptor)
|
||||
return LookupResult(item, annotation_item)
|
||||
else:
|
||||
item = self.lookup_item(descriptor)
|
||||
if item is not None:
|
||||
return LookupResult(item)
|
||||
|
||||
def shallowitem_from_descriptor(self, descriptor, is_annotation=False):
|
||||
return self.make_s3item_from_key(
|
||||
basename(descriptor), descriptor, is_annotation, True
|
||||
)
|
||||
|
||||
def make_s3item_from_key(
|
||||
self, name, s3key, is_annotation=False, is_shallow=False
|
||||
) -> S3Item:
|
||||
item = S3Item(
|
||||
s3key=s3key,
|
||||
name=name,
|
||||
type=ItemType.annotation if is_annotation else ItemType.h5ad,
|
||||
)
|
||||
|
||||
if not is_annotation and not is_shallow:
|
||||
annotations = self.make_annotations_for_fileitem(item)
|
||||
item.annotations = annotations
|
||||
|
||||
return item
|
||||
|
||||
def make_annotations_for_fileitem(self, item: S3Item) -> List[S3Item]:
|
||||
annotations_subpath = self.get_annotations_subpath(item)
|
||||
annotations_fullpath = self.url(annotations_subpath)
|
||||
if self.s3.isdir(annotations_fullpath):
|
||||
return [
|
||||
self.make_s3item_from_key(
|
||||
basename(annotation), self.remove_bucket(annotation), True
|
||||
)
|
||||
for annotation in sorted(
|
||||
self.s3.ls(annotations_fullpath, refresh=self.refresh)
|
||||
)
|
||||
if annotation.endswith(self.annotation_file_suffix)
|
||||
and self.s3.isfile("s3://" + annotation)
|
||||
]
|
||||
else:
|
||||
return None
|
||||
@@ -1,49 +0,0 @@
|
||||
# Copyright 2019 Novartis Institutes for BioMedical Research Inc. Licensed
|
||||
# under the Apache License, Version 2.0 (the "License"); you may not use
|
||||
# this file except in compliance with the License. You may obtain a copy
|
||||
# of the License at http://www.apache.org/licenses/LICENSE-2.0. Unless
|
||||
# required by applicable law or agreed to in writing, software distributed
|
||||
# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES
|
||||
# OR CONDITIONS OF ANY KIND, either express or implied. See the License for
|
||||
# the specific language governing permissions and limitations under the License.
|
||||
|
||||
import os
|
||||
|
||||
from flask_api import status
|
||||
|
||||
from cellxgene_gateway import env
|
||||
from cellxgene_gateway.cellxgene_exception import CellxgeneException
|
||||
|
||||
|
||||
def get_dataset(path):
|
||||
if path == "/" or path == "":
|
||||
raise CellxgeneException(
|
||||
"No matching dataset found.", status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
trimmed = path[:-1] if path[-1] == "/" else path
|
||||
|
||||
try:
|
||||
get_file_path(trimmed)
|
||||
return trimmed
|
||||
except CellxgeneException:
|
||||
split = os.path.split(trimmed)
|
||||
return get_dataset(split[0])
|
||||
|
||||
|
||||
def validate_path(file_path):
|
||||
if not os.path.exists(file_path):
|
||||
raise CellxgeneException(
|
||||
"File does not exist: " + file_path, status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
if not os.path.isfile(file_path):
|
||||
raise CellxgeneException(
|
||||
"Path is not file: " + file_path, status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
def get_file_path(dataset):
|
||||
file_path = os.path.join(env.cellxgene_data, dataset)
|
||||
validate_path(file_path)
|
||||
return file_path
|
||||
@@ -9,13 +9,13 @@
|
||||
|
||||
|
||||
class ProcessException(Exception):
|
||||
def __init__(self, message, stdout, stderr, http_status, dataset):
|
||||
def __init__(self, message, stdout, stderr, http_status, key):
|
||||
Exception.__init__(self)
|
||||
self.message = message
|
||||
self.stdout = stdout
|
||||
self.stderr = stderr
|
||||
self.http_status = http_status
|
||||
self.dataset = dataset
|
||||
self.key = key
|
||||
|
||||
@classmethod
|
||||
def from_cache_entry(cls, cache_entry):
|
||||
@@ -24,5 +24,5 @@ class ProcessException(Exception):
|
||||
cache_entry.all_output,
|
||||
cache_entry.stderr,
|
||||
cache_entry.http_status,
|
||||
cache_entry.dataset,
|
||||
cache_entry.key,
|
||||
)
|
||||
|
||||
@@ -7,17 +7,18 @@
|
||||
# OR CONDITIONS OF ANY KIND, either express or implied. See the License for
|
||||
# the specific language governing permissions and limitations under the License.
|
||||
|
||||
import time
|
||||
import logging
|
||||
import time
|
||||
|
||||
from cellxgene_gateway.util import current_time_stamp
|
||||
from cellxgene_gateway.env import ttl
|
||||
from cellxgene_gateway import env, util
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PruneProcessCache:
|
||||
def __init__(self, cache):
|
||||
self.cache = cache
|
||||
self.expire_seconds = (3600 if ttl is None else int(ttl))
|
||||
self.expire_seconds = env.expire_seconds
|
||||
|
||||
def __call__(self):
|
||||
while True:
|
||||
@@ -25,16 +26,22 @@ class PruneProcessCache:
|
||||
self.prune()
|
||||
|
||||
def prune(self):
|
||||
timestamp = current_time_stamp()
|
||||
timestamp = util.current_time_stamp()
|
||||
cutoff = timestamp - self.expire_seconds
|
||||
processes_to_delete = [p for p in self.cache.entry_list if p.timestamp < cutoff]
|
||||
processes_to_keep = [p for p in self.cache.entry_list if not p.timestamp < cutoff]
|
||||
logger = logging.getLogger("cellxgene_gateway")
|
||||
logger.debug(f"Cutoff {cutoff} = timestamp {timestamp} - expire seconds {self.expire_seconds} , keeping {processes_to_keep}")
|
||||
|
||||
processes_to_delete = [p for p in self.cache.entry_list if p.timestamp < cutoff]
|
||||
processes_to_keep = [
|
||||
p for p in self.cache.entry_list if not p.timestamp < cutoff
|
||||
]
|
||||
|
||||
logger.debug(
|
||||
f"Cutoff {cutoff} = timestamp {timestamp} - expire seconds {self.expire_seconds} , keeping {processes_to_keep}, pruning {processes_to_delete}"
|
||||
)
|
||||
|
||||
for process in processes_to_delete:
|
||||
try:
|
||||
logger.info(f"pruning process {process.pid} ({process.dataset})")
|
||||
logger.info(f"pruning process {process.pid} ({process.key.descriptor})")
|
||||
self.cache.prune(process)
|
||||
except Exception:
|
||||
logger.exception("failed to prune process {process.pid} ({process.dataset})")
|
||||
logger.exception(
|
||||
"failed to prune process {process.pid} ({process.key.descriptor})"
|
||||
)
|
||||
|
||||
20
cellxgene_gateway/static/js/annotation.js
Normal file
20
cellxgene_gateway/static/js/annotation.js
Normal file
@@ -0,0 +1,20 @@
|
||||
// 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.
|
||||
const new_annotation_callback = (() =>{
|
||||
const suffix = `.csv`;
|
||||
return (e) => {
|
||||
e.preventDefault();
|
||||
const el = $(e.target);
|
||||
const href = el.attr('href');
|
||||
const base = prompt(`Name your annotations collection\nnote: the suffix "${suffix}" will be appended`);
|
||||
if (base !== null && base.length > 0) {
|
||||
if (/^[0-9a-zA-Z_]+$/.test(base)) {
|
||||
window.location = `${href}/${base}${suffix}`;
|
||||
} else {
|
||||
alert("Error: name must match ^[0-9a-zA-Z_]+$\nthat is, only numbers, letters and underscore are allowed")
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
})()
|
||||
|
||||
@@ -12,20 +12,36 @@ import subprocess
|
||||
|
||||
from flask_api import status
|
||||
|
||||
from cellxgene_gateway.cache_entry import CacheEntryStatus
|
||||
from cellxgene_gateway.dir_util import make_annotations
|
||||
from cellxgene_gateway.env import cellxgene_args, enable_annotations, enable_backed_mode
|
||||
from cellxgene_gateway.process_exception import ProcessException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SubprocessBackend:
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def create_cmd(self, cellxgene_loc, file_path, port, scripts):
|
||||
def create_cmd(self, cellxgene_loc, file_path, port, scripts, annotation_file_path):
|
||||
if enable_annotations and not annotation_file_path is None:
|
||||
if annotation_file_path == "":
|
||||
extra_args = f" --annotations-dir {make_annotations(file_path)}"
|
||||
else:
|
||||
extra_args = f" --annotations-file {annotation_file_path}"
|
||||
else:
|
||||
extra_args = " --disable-annotations"
|
||||
if enable_backed_mode:
|
||||
extra_args += " --backed"
|
||||
if not cellxgene_args is None:
|
||||
extra_args += f" {cellxgene_args}"
|
||||
|
||||
cmd = (
|
||||
f"yes | {cellxgene_loc} launch {file_path}"
|
||||
+ " --port "
|
||||
+ str(port)
|
||||
+ f" --port {port}"
|
||||
+ " --host 127.0.0.1"
|
||||
+ extra_args
|
||||
)
|
||||
|
||||
for s in scripts:
|
||||
@@ -34,11 +50,14 @@ class SubprocessBackend:
|
||||
return cmd
|
||||
|
||||
def launch(self, cellxgene_loc, scripts, cache_entry):
|
||||
|
||||
cmd = self.create_cmd(
|
||||
cellxgene_loc, cache_entry.file_path, cache_entry.port, scripts
|
||||
cellxgene_loc,
|
||||
cache_entry.key.file_path,
|
||||
cache_entry.port,
|
||||
scripts,
|
||||
cache_entry.key.annotation_file_path,
|
||||
)
|
||||
logging.getLogger("cellxgene_gateway").info(f"launching {cmd}")
|
||||
logger.info(f"launching {cmd}")
|
||||
process = subprocess.Popen(
|
||||
[cmd], stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True
|
||||
)
|
||||
@@ -59,7 +78,7 @@ class SubprocessBackend:
|
||||
message = "Cellxgene failed to launch dataset."
|
||||
http_status = status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
|
||||
cache_entry.status = "error"
|
||||
cache_entry.status = CacheEntryStatus.error
|
||||
cache_entry.set_error(message, stderr, http_status)
|
||||
|
||||
raise ProcessException.from_cache_entry(cache_entry)
|
||||
@@ -67,5 +86,6 @@ class SubprocessBackend:
|
||||
cache_entry.append_output(output)
|
||||
|
||||
cache_entry.set_loaded(process.pid)
|
||||
|
||||
return
|
||||
for output in process.communicate():
|
||||
logger.debug(f"cellxgene:{output}")
|
||||
logger.info(f"exiting {cmd}")
|
||||
|
||||
@@ -10,63 +10,77 @@
|
||||
-->
|
||||
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<title>Cellxgene Gateway - FILE CRAWLER</title>
|
||||
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
|
||||
<link rel="icon" type="image/png" href="{{ url_for('static', filename='nibr.ico') }}">
|
||||
{% for script in extra_scripts %}
|
||||
<script src="{{ script }}"></script>
|
||||
{% endfor %}
|
||||
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css" integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO" crossorigin="anonymous">
|
||||
{% for script in extra_scripts %}
|
||||
<script src="{{ script }}"></script>
|
||||
{% endfor %}
|
||||
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css"
|
||||
integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO" crossorigin="anonymous">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<header class="navbar navbar-expand navbar-dark flex-column flex-md-row bd-navbar">
|
||||
<h3>Cellxgene Gateway - Cache Status</h3>
|
||||
<h3>Cellxgene Gateway - Cache Status</h3>
|
||||
</header>
|
||||
<br>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>PID</th>
|
||||
<th>dataset</th>
|
||||
<th>port</th>
|
||||
<th>launchtime</th>
|
||||
<th>last access</th>
|
||||
<th>status</th>
|
||||
<th>message</th>
|
||||
<th>http_status</th>
|
||||
<th>actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in entry_list %}
|
||||
<tr>
|
||||
<td>{{ entry.pid }}</td>
|
||||
<td><a href="{{ url_for('do_view', path=entry.dataset) }}">{{ entry.dataset }}</a></td>
|
||||
<td>{{ entry.port }}</td>
|
||||
<td class="timestamp">{{ entry.launchtime }}</td>
|
||||
<td class="timestamp">{{ entry.timestamp }}</td>
|
||||
<td>{{ entry.status }}</td>
|
||||
<td>{{ entry.message }}</td>
|
||||
<td>{{ entry.http_status }}</td>
|
||||
<td>
|
||||
{% if entry.status == 'loaded' %}
|
||||
<a href="{{ url_for('do_terminate', path=entry.dataset) }}"> terminate </a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tr>
|
||||
<th>PID</th>
|
||||
<th>dataset</th>
|
||||
<th>annotation_file</th>
|
||||
<th>source</th>
|
||||
<th>port</th>
|
||||
<th>launchtime</th>
|
||||
<th>last access</th>
|
||||
<th>status</th>
|
||||
<th>message</th>
|
||||
<th>http_status</th>
|
||||
<th>actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in entry_list %}
|
||||
<tr>
|
||||
<td>{{ entry.pid }}</td>
|
||||
<td><a
|
||||
href="{{ entry.key.view_url }}">{{ entry.key.h5ad_item.descriptor }}</a>
|
||||
</td>
|
||||
<td>{{ entry.key.annotation_descriptor }}</td>
|
||||
<td>{{ entry.source_name }}</td>
|
||||
<td>{{ entry.port }}</td>
|
||||
<td class="timestamp">{{ entry.launchtime }}</td>
|
||||
<td class="timestamp">{{ entry.timestamp }}</td>
|
||||
<td>{{ entry.status.name }}</td>
|
||||
<td>{{ entry.message }}</td>
|
||||
<td>{{ entry.http_status }}</td>
|
||||
<td>
|
||||
{% if entry.status.name == 'loaded' %}
|
||||
<a
|
||||
href="{{ url_for('do_terminate', path=entry.key.descriptor, source_name=entry.key.source_name) }}">
|
||||
terminate </a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<script>
|
||||
$(() => {
|
||||
$(".timestamp").each(function(){
|
||||
$(".timestamp").each(function () {
|
||||
const el = $(this);
|
||||
const ts = el.text();
|
||||
const dt = new Date(parseInt(ts * 1000));
|
||||
el.html(`${dt.toISOString()}<br>(${ts})`);
|
||||
el.prepend(`${dt.toISOString()}<br>(`);
|
||||
el.append(')');
|
||||
|
||||
});
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
</html>
|
||||
@@ -28,11 +28,11 @@
|
||||
|
||||
<h4>{{ message }}</h4>
|
||||
|
||||
<a href="/filecrawl.html">
|
||||
<a href="{{ url_for('filecrawl') }}">
|
||||
Please click here to be redirected to the file directory.
|
||||
</a>
|
||||
<br>
|
||||
<a href="/">
|
||||
<a href="{{ url_for('index') }}">
|
||||
Please click here to return to the homepage.
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -16,19 +16,36 @@
|
||||
<link rel="icon" type="image/png" href="{{ url_for('static', filename='nibr.ico') }}">
|
||||
{% for script in extra_scripts %}
|
||||
<script src="{{ script }}"></script>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
<script src="{{ url_for('static', filename='js/annotation.js') }}"></script>
|
||||
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css" integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO" crossorigin="anonymous">
|
||||
</head>
|
||||
<body>
|
||||
<header class="navbar navbar-expand navbar-dark flex-column flex-md-row bd-navbar">
|
||||
<h3>Cellxgene Gateway - FILE CRAWLER</h3>
|
||||
{% if path %}
|
||||
<h3>Cellxgene Gateway - {{ path }}</h3>
|
||||
{% else %}
|
||||
<h3>Cellxgene Gateway - FILE CRAWLER</h3>
|
||||
{% endif %}
|
||||
</header>
|
||||
<br>
|
||||
|
||||
<h4>Please click on a dataset to view it in Cellxgene Server.</h4>
|
||||
|
||||
<br>
|
||||
{{ rendered_html|safe }}
|
||||
|
||||
<p>
|
||||
Navigation:
|
||||
<ul>
|
||||
{% if path %}
|
||||
<li><a href="{{ url_for('filecrawl') }}">top level</a></li>
|
||||
{% else %}
|
||||
{% endif %}
|
||||
<li><a href="{{ url_for('index') }}">homepage</a></li>
|
||||
</ul>
|
||||
</p>
|
||||
<script>
|
||||
$(() => {
|
||||
$("a.new").click(new_annotation_callback);
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -35,56 +35,15 @@
|
||||
Links:
|
||||
</h1>
|
||||
<div class="list-group" style="width:50%;padding-left:65px">
|
||||
<a href="/filecrawl.html" class="list-group-item list-group-item-action">
|
||||
<a href="{{ url_for('filecrawl') }}" class="list-group-item list-group-item-action">
|
||||
<u>File Crawler: Allows you to view all uploaded data.</u></a>
|
||||
|
||||
</div>
|
||||
<div class="list-group" style="width:50%;padding-left:65px">
|
||||
<a href="/cache_status" class="list-group-item list-group-item-action">
|
||||
<a href="{{ url_for('do_GET_status') }}" class="list-group-item list-group-item-action">
|
||||
<u>Cache Status: view status of launched cellxgene servers.</u></a>
|
||||
</div>
|
||||
|
||||
{% if enable_upload %}
|
||||
<br>
|
||||
<h1 style="padding-left:35px">
|
||||
How To Upload Data via HTTP:
|
||||
</h1>
|
||||
<ol style="padding-left:85px;">
|
||||
<li>
|
||||
Create a folder for your Username:
|
||||
</li>
|
||||
<br>
|
||||
<form action="{{ url_for('make_user') }}" method="post">
|
||||
Username <input type="text" name="directory">
|
||||
<input type="submit" value="Create">
|
||||
</form>
|
||||
<li>
|
||||
Create a subdirectory under the selected Folder:
|
||||
</li>
|
||||
<br>
|
||||
<form action="{{ url_for('make_subdir') }}" method="post">
|
||||
<select name="usernames" id="usernames">
|
||||
{% for user in users %}
|
||||
<option value="{{ user }}">{{ user }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<br>
|
||||
Subdirectory Name <input type="text" name="directory">
|
||||
<input type="submit" value="Create">
|
||||
</form>
|
||||
<li>Choose a folder to copy your data to, then upload your data file (must be in .h5ad format).</li>
|
||||
<br>
|
||||
<form action="{{ url_for('upload_file') }}" method="post" enctype="multipart/form-data">
|
||||
Type in the name of the directory and subdirectory you wish to upload to, i.e. "USER/cells". <input type="text" name="path">
|
||||
<br>
|
||||
File: <input type="file" name="file"><br>
|
||||
<input style="position:relative; top:10px;" type="submit" value="Upload">
|
||||
</form>
|
||||
<br>
|
||||
<li>Take a look at your data using the file crawler link above</li>
|
||||
</ol>
|
||||
{% endif %}
|
||||
|
||||
<br>
|
||||
|
||||
<h1 style="padding-left:35px">
|
||||
|
||||
@@ -35,18 +35,22 @@
|
||||
The page will refresh shortly.
|
||||
</p>
|
||||
|
||||
<a href="/filecrawl.html">
|
||||
<a href="{{ url_for('filecrawl') }}">
|
||||
Please click here to be redirected to the file directory.
|
||||
</a>
|
||||
<br>
|
||||
<a href="/">
|
||||
<a href="{{ url_for('index') }}">
|
||||
Please click here to return to the homepage.
|
||||
</a>
|
||||
</div>
|
||||
<script>
|
||||
var count = 0;
|
||||
window.setInterval(function(){
|
||||
var dots = document.getElementById('dots');
|
||||
dots.textContent = dots.textContent + '.';
|
||||
if (count++ > 5) {
|
||||
window.location.reload();
|
||||
}
|
||||
}, 1000);
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -36,13 +36,13 @@
|
||||
<h4>Options</h4>
|
||||
Please choose one of the following, or use the back button:
|
||||
<ul>
|
||||
<li><a href="{{url_for('do_relaunch', path=dataset)}}">
|
||||
<li><a href="{{ relaunch_url }}">
|
||||
Attempt to relaunch the cellxgene server.
|
||||
</a></li>
|
||||
<li><a href="/filecrawl.html">
|
||||
<li><a href="{{ url_for('filecrawl') }}">
|
||||
Return to the file directory.
|
||||
</a></li>
|
||||
<li><a href="/">
|
||||
<li><a href="{{ url_for('index') }}">
|
||||
Return to the homepage.
|
||||
</a></li>
|
||||
</ul>
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
name: cellxgene-dev
|
||||
channels:
|
||||
- conda-forge
|
||||
dependencies:
|
||||
- python=3.7
|
||||
- requests
|
||||
- flask
|
||||
- psutil
|
||||
- pip:
|
||||
- flask-api
|
||||
- cellxgene
|
||||
18
environment.yml
Normal file
18
environment.yml
Normal file
@@ -0,0 +1,18 @@
|
||||
name: cellxgene-gateway
|
||||
channels:
|
||||
- conda-forge
|
||||
dependencies:
|
||||
- python=3.9
|
||||
- requests
|
||||
- flask
|
||||
- psutil
|
||||
- black
|
||||
- twine
|
||||
- isort
|
||||
- coverage
|
||||
- pip
|
||||
- pip:
|
||||
- pre_commit
|
||||
- flask-api
|
||||
- werkzeug
|
||||
- cellxgene
|
||||
14
examples/customized_docker_image/Dockerfile
Normal file
14
examples/customized_docker_image/Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
||||
FROM python:3.9
|
||||
|
||||
RUN pip install cellxgene-gateway 'MarkupSafe<2.1'
|
||||
|
||||
COPY customize_ui.sh customize_ui.sh
|
||||
RUN CELLXGENE_GATEWAY_DIR=/usr/local/lib/python3.9/site-packages/cellxgene_gateway . ./customize_ui.sh
|
||||
|
||||
ENV CELLXGENE_DATA=/cellxgene-data
|
||||
ENV CELLXGENE_LOCATION=/usr/local/bin/cellxgene
|
||||
EXPOSE 5005
|
||||
|
||||
RUN mkdir /cellxgene-data
|
||||
|
||||
CMD ["cellxgene-gateway"]
|
||||
14
examples/customized_docker_image/README.md
Normal file
14
examples/customized_docker_image/README.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# Purpose
|
||||
|
||||
This is a simple example of how to make a small script to customize the UI of cellxgene-gateway. The script that does the customization is `customize_ui.sh`, it simply makes the main header green using CSS but you could do anything you want there (including adding more script tags, etc).
|
||||
|
||||
# Usage
|
||||
|
||||
```
|
||||
docker build -t cellxgene_custom .
|
||||
CELLXGENE_DATA=`pwd`/../../../cellxgene_data
|
||||
docker run -p 5005:5005 --mount src=$CELLXGENE_DATA,target=/cellxgene-data,type=bind cellxgene_custom
|
||||
```
|
||||
|
||||
If you now open http://localhost:5005 you should see a green cellxgene gateway header.
|
||||
|
||||
3
examples/customized_docker_image/customize_ui.sh
Normal file
3
examples/customized_docker_image/customize_ui.sh
Normal file
@@ -0,0 +1,3 @@
|
||||
# make the header bright green
|
||||
find "${CELLXGENE_GATEWAY_DIR}/templates" -name index.html -exec sed -i -e 's/<head>/<head>\
|
||||
> <style> header h3 {color: #0F0;} <\/style>/g' {} \;
|
||||
@@ -1,5 +1,6 @@
|
||||
cellxgene
|
||||
flask
|
||||
flask_api
|
||||
flask-api
|
||||
werkzeug
|
||||
psutil
|
||||
requests
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
export CELLXGENE_LOCATION=$(pwd)/.cellxgene-gateway/bin/cellxgene
|
||||
export CELLXGENE_DATA=../cellxgene_data
|
||||
export DEPLOYMENT_ENV=dev
|
||||
export GATEWAY_HOST=localhost:5005
|
||||
export GATEWAY_PROTOCOL=http
|
||||
export GATEWAY_IP=127.0.0.1
|
||||
|
||||
#Once these are set, you run like a normal Flask app
|
||||
|
||||
45
setup.py
45
setup.py
@@ -1,40 +1,69 @@
|
||||
import codecs
|
||||
import os
|
||||
from setuptools import setup
|
||||
import sys
|
||||
|
||||
from setuptools import find_packages, setup
|
||||
|
||||
if sys.version_info < (3, 6):
|
||||
sys.exit("Sorry, Python < 3.6 is not supported")
|
||||
|
||||
|
||||
def read(rel_path):
|
||||
here = os.path.abspath(os.path.dirname(__file__))
|
||||
with codecs.open(os.path.join(here, rel_path), "r") as fp:
|
||||
return fp.read()
|
||||
|
||||
|
||||
def get_version(rel_path):
|
||||
for line in read(rel_path).splitlines():
|
||||
if line.startswith("__version__"):
|
||||
delim = '"' if '"' in line else "'"
|
||||
return line.split(delim)[1]
|
||||
else:
|
||||
raise RuntimeError("Unable to find version string.")
|
||||
|
||||
|
||||
def parse_requirements():
|
||||
reqs = []
|
||||
with open("requirements.txt", "r") as f:
|
||||
for l in f.readlines():
|
||||
reqs.append(l.strip("\n"))
|
||||
for line in f.readlines():
|
||||
reqs.append(line.strip("\n"))
|
||||
return reqs
|
||||
|
||||
|
||||
with open("README.md", "r") as fh:
|
||||
long_description = fh.read()
|
||||
|
||||
install_reqs = parse_requirements()
|
||||
|
||||
setup(
|
||||
# mandatory
|
||||
name="cellxgene-gateway",
|
||||
# mandatory
|
||||
version="0.1",
|
||||
version=get_version("cellxgene_gateway/__init__.py"),
|
||||
# mandatory
|
||||
author="Niket Patel, Yohann Potier, Alok Saldanha",
|
||||
author_email="alok.saldanha@novartis.com",
|
||||
description=("Cellxgene Gateway"),
|
||||
long_description=long_description,
|
||||
long_description_content_type="text/markdown",
|
||||
license="MIT",
|
||||
keywords="visualization, genomics",
|
||||
url="http://github.com/Novartis/cellxgene-gateway",
|
||||
packages=["cellxgene_gateway"],
|
||||
packages=find_packages(),
|
||||
package_data={
|
||||
"cellxgene_gateway": [
|
||||
"static/css/homepagestyle.css",
|
||||
"static/js/annotation.js",
|
||||
"static/nibr.ico",
|
||||
"templates/*.html"
|
||||
]},
|
||||
data_files=[('', ['Readme.md', 'LICENSE.txt'])],
|
||||
"templates/*.html",
|
||||
]
|
||||
},
|
||||
data_files=[("", ["README.md", "LICENSE"])],
|
||||
install_requires=install_reqs,
|
||||
entry_points={
|
||||
"console_scripts": ["cellxgene-gateway=cellxgene_gateway.gateway:main"]
|
||||
},
|
||||
classifiers=["Topic :: Scientific/Engineering :: Visualization"],
|
||||
python_requires=">=3.6",
|
||||
)
|
||||
|
||||
0
tests/items/__init__.py
Normal file
0
tests/items/__init__.py
Normal file
0
tests/items/file/__init__.py
Normal file
0
tests/items/file/__init__.py
Normal file
43
tests/items/file/test_fileitem_source.py
Normal file
43
tests/items/file/test_fileitem_source.py
Normal file
@@ -0,0 +1,43 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from cellxgene_gateway.items.file.fileitem_source import FileItemSource
|
||||
|
||||
|
||||
def stub_join(path):
|
||||
path.join = lambda x, y: x + "/" + y
|
||||
|
||||
|
||||
class TestFileItemSource(unittest.TestCase):
|
||||
@patch("os.path")
|
||||
@patch("os.listdir")
|
||||
def test_list_items_GIVEN_no_subpath_THEN_checks_dir(self, listdir, path):
|
||||
stub_join(path)
|
||||
source = FileItemSource("/tmp/unittest", "local")
|
||||
source.list_items()
|
||||
path.exists.assert_called_once_with("/tmp/unittest/")
|
||||
|
||||
@patch("os.path")
|
||||
@patch("os.listdir")
|
||||
def test_list_items_GIVEN_subpath_THEN_checks_subpath(self, listdir, path):
|
||||
stub_join(path)
|
||||
source = FileItemSource("/tmp/unittest", "local")
|
||||
source.list_items("foo")
|
||||
path.exists.assert_called_once_with("/tmp/unittest/foo")
|
||||
|
||||
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")
|
||||
0
tests/items/s3/__init__.py
Normal file
0
tests/items/s3/__init__.py
Normal file
172
tests/items/s3/test_s3item_source.py
Normal file
172
tests/items/s3/test_s3item_source.py
Normal file
@@ -0,0 +1,172 @@
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
from cellxgene_gateway.gateway import app
|
||||
from cellxgene_gateway.items.item import ItemType
|
||||
from cellxgene_gateway.items.s3.s3item import S3Item
|
||||
from cellxgene_gateway.items.s3.s3item_source import S3ItemSource
|
||||
|
||||
|
||||
class TestScanDirectory(unittest.TestCase):
|
||||
@patch("s3fs.S3FileSystem")
|
||||
def test_GIVEN_invalid_bucket_THEN_throws_error(self, s3func):
|
||||
class S3Mock:
|
||||
def exists(path):
|
||||
if path in ["s3://my-bucket/"]:
|
||||
return False
|
||||
|
||||
s3func.return_value = S3Mock
|
||||
source = S3ItemSource("my-bucket")
|
||||
with self.assertRaises(Exception) as context:
|
||||
source.scan_directory()
|
||||
self.assertEqual(
|
||||
"S3 url 's3://my-bucket/' does not exist.",
|
||||
str(context.exception),
|
||||
)
|
||||
|
||||
@patch("s3fs.S3FileSystem")
|
||||
def test__GIVEN_multilevel_bucket_THEN_properly_recurses_suburls(self, s3func):
|
||||
class S3Mock:
|
||||
def exists(path):
|
||||
if path in [
|
||||
"s3://my-bucket/",
|
||||
"s3://my-bucket/pbmc3k.h5ad",
|
||||
"s3://my-bucket/lvl1",
|
||||
"s3://my-bucket/lvl1/pbmc3k_l1.h5ad",
|
||||
"s3://my-bucket/lvl1/lvl2",
|
||||
"s3://my-bucket/lvl1/lvl2/pbmc3k_l2.h5ad",
|
||||
]:
|
||||
return True
|
||||
raise Exception("exists called with " + path)
|
||||
|
||||
def ls(path, refresh):
|
||||
assert refresh == True
|
||||
if path == "s3://my-bucket/":
|
||||
return [
|
||||
"my-bucket/lvl1",
|
||||
"my-bucket/pbmc3k.h5ad",
|
||||
"my-bucket/pbmc3k_annotations",
|
||||
]
|
||||
elif path == "s3://my-bucket/pbmc3k_annotations":
|
||||
return ["my-bucket/pbmc3k_annotations/annot.csv"]
|
||||
elif path == "s3://my-bucket/lvl1":
|
||||
return ["my-bucket/lvl1/lvl2", "my-bucket/lvl1/pbmc3k_l1.h5ad"]
|
||||
elif path == "s3://my-bucket/lvl1/lvl2":
|
||||
return ["my-bucket/lvl1/lvl2/pbmc3k_l2.h5ad"]
|
||||
|
||||
raise Exception("ls called with " + path)
|
||||
|
||||
def isdir(path):
|
||||
if path in [
|
||||
"s3://my-bucket/lvl1",
|
||||
"s3://my-bucket/pbmc3k_annotations",
|
||||
"s3://my-bucket/lvl1/lvl2",
|
||||
]:
|
||||
return True
|
||||
if path in [
|
||||
"s3://my-bucket/pbmc3k.h5ad",
|
||||
"s3://my-bucket/lvl1/pbmc3k_l1.h5ad",
|
||||
"s3://my-bucket/lvl1/pbmc3k_l1_annotations",
|
||||
"s3://my-bucket/lvl1/lvl2/pbmc3k_l2.h5ad",
|
||||
"s3://my-bucket/lvl1/lvl2/pbmc3k_l2_annotations",
|
||||
]:
|
||||
return False
|
||||
raise Exception("isdir called with " + path)
|
||||
|
||||
def isfile(path):
|
||||
if path in ["s3://my-bucket/pbmc3k_annotations/annot.csv"]:
|
||||
return True
|
||||
if path in ["s3://my-bucket/pbmc3k_annotations"]:
|
||||
return False
|
||||
raise Exception("isfile called with " + path)
|
||||
|
||||
s3func.return_value = S3Mock
|
||||
source = S3ItemSource("my-bucket")
|
||||
with app.test_request_context(query_string="refresh=true") as test_context:
|
||||
tree = source.scan_directory()
|
||||
|
||||
def s3item_compare(i1, i2, msg=""):
|
||||
self.assertEqual(i1.name, i2.name, "name equals")
|
||||
self.assertEqual(i1.type, i2.type, "type equals")
|
||||
self.assertEqual(i1.s3key, i2.s3key, "s3key equals")
|
||||
if i1.annotations is None:
|
||||
self.assertEqual(i1.annotations, i2.annotations, "annotations equals")
|
||||
else:
|
||||
self.assertEqual(
|
||||
len(i1.annotations),
|
||||
len(i2.annotations),
|
||||
"annotations length equals",
|
||||
)
|
||||
for a1, a2 in zip(i1.annotations, i2.annotations):
|
||||
self.assertEqual(a1, a2)
|
||||
return True
|
||||
|
||||
self.addTypeEqualityFunc(S3Item, s3item_compare)
|
||||
|
||||
def assertTree(t, descriptor, items):
|
||||
self.assertEqual(t.descriptor, descriptor)
|
||||
self.assertEqual(len(t.items), len(items))
|
||||
for i1, i2 in zip(t.items, items):
|
||||
self.assertEqual(i1, i2)
|
||||
|
||||
assertTree(
|
||||
tree,
|
||||
"",
|
||||
[
|
||||
S3Item(
|
||||
"pbmc3k.h5ad",
|
||||
name="pbmc3k.h5ad",
|
||||
type=ItemType.h5ad,
|
||||
annotations=[
|
||||
S3Item(
|
||||
"pbmc3k_annotations/annot.csv",
|
||||
name="annot.csv",
|
||||
type=ItemType.annotation,
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
self.assertEqual(len(tree.branches), 1)
|
||||
lvl1 = tree.branches[0]
|
||||
assertTree(
|
||||
lvl1,
|
||||
"lvl1",
|
||||
[
|
||||
S3Item(
|
||||
"lvl1/pbmc3k_l1.h5ad",
|
||||
name="pbmc3k_l1.h5ad",
|
||||
type=ItemType.h5ad,
|
||||
annotations=None,
|
||||
)
|
||||
],
|
||||
)
|
||||
self.assertEqual(len(lvl1.branches), 1)
|
||||
lvl2 = lvl1.branches[0]
|
||||
assertTree(
|
||||
lvl2,
|
||||
"lvl1/lvl2",
|
||||
[
|
||||
S3Item(
|
||||
"lvl1/lvl2/pbmc3k_l2.h5ad",
|
||||
name="pbmc3k_l2.h5ad",
|
||||
type=ItemType.h5ad,
|
||||
annotations=None,
|
||||
)
|
||||
],
|
||||
)
|
||||
self.assertEqual(lvl2.branches, None)
|
||||
|
||||
|
||||
class TestListItems(unittest.TestCase):
|
||||
def test_GIVEN_filter_THEN_pass_filter_into_scan_directory(self):
|
||||
source = S3ItemSource("my-bucket")
|
||||
source.scan_directory = MagicMock()
|
||||
tree = source.list_items("some-filter")
|
||||
source.scan_directory.assert_called_once_with("some-filter")
|
||||
|
||||
def test_GIVEN_no_filter_THEN_pass_empty_string_into_scan_directory(self):
|
||||
source = S3ItemSource("my-bucket")
|
||||
source.scan_directory = MagicMock()
|
||||
tree = source.list_items()
|
||||
source.scan_directory.assert_called_once_with("")
|
||||
28
tests/test_backend_cache.py
Normal file
28
tests/test_backend_cache.py
Normal file
@@ -0,0 +1,28 @@
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from cellxgene_gateway.backend_cache import is_port_in_use
|
||||
|
||||
|
||||
class TestIsPortInUse(unittest.TestCase):
|
||||
@patch("socket.socket")
|
||||
def test_GIVEN_free_port_THEN_returns_true(self, socketMock):
|
||||
connectMock = socketMock()
|
||||
connectMock.connect_ex.return_value = 0
|
||||
connectMock.__enter__.return_value = connectMock
|
||||
self.assertEqual(is_port_in_use(123), True)
|
||||
self.assertTrue(connectMock.__enter__.calledOnce)
|
||||
self.assertTrue(connectMock.__exit__.calledOnce)
|
||||
self.assertTrue(connectMock.connect_ex.calledOnceWith("a"))
|
||||
self.assertTrue(socketMock.calledOnceWith("a"))
|
||||
|
||||
@patch("socket.socket")
|
||||
def test_GIVEN_used_port_THEN_returns_false(self, socketMock):
|
||||
connectMock = socketMock()
|
||||
connectMock.__enter__.return_value = connectMock
|
||||
connectMock.connect_ex.return_value = 1
|
||||
self.assertTrue(connectMock.__enter__.calledOnce)
|
||||
self.assertTrue(connectMock.__exit__.calledOnce)
|
||||
self.assertTrue(connectMock.connect_ex.calledOnceWith("a"))
|
||||
self.assertTrue(socketMock.calledOnceWith("a"))
|
||||
self.assertEqual(is_port_in_use(123), False)
|
||||
64
tests/test_cache_entry.py
Normal file
64
tests/test_cache_entry.py
Normal file
@@ -0,0 +1,64 @@
|
||||
import unittest
|
||||
|
||||
from flask import Flask
|
||||
|
||||
from cellxgene_gateway import flask_util
|
||||
from cellxgene_gateway.cache_entry import CacheEntry, CacheEntryStatus
|
||||
from cellxgene_gateway.cache_key import CacheKey
|
||||
from cellxgene_gateway.gateway import app
|
||||
from cellxgene_gateway.items.file.fileitem import FileItem
|
||||
from cellxgene_gateway.items.file.fileitem_source import FileItemSource
|
||||
from cellxgene_gateway.items.item import ItemType
|
||||
|
||||
key = CacheKey(
|
||||
FileItem("/czi/", name="pbmc3k.h5ad", type=ItemType.h5ad),
|
||||
FileItemSource("/tmp", "local"),
|
||||
)
|
||||
|
||||
|
||||
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):
|
||||
entry = CacheEntry.for_key("some-key", 1)
|
||||
self.assertEqual(entry.status, CacheEntryStatus.loading)
|
||||
|
||||
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(
|
||||
"src:url(/static/assets/"
|
||||
)
|
||||
expected = "src:url(/view/czi/pbmc3k.h5ad/static/assets/"
|
||||
self.assertEqual(actual, expected)
|
||||
|
||||
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(
|
||||
'<link rel="shortcut icon" href="/static/assets/favicon.ico">'
|
||||
)
|
||||
expected = '<link rel="shortcut icon" href="/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)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,38 +1,35 @@
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
from cellxgene_gateway.dir_util import render_entry
|
||||
|
||||
class TestRenderEntry(unittest.TestCase):
|
||||
def test_GIVEN_path_both_slash_THEN_view_has_single_slash(self):
|
||||
entry = {
|
||||
"path": "/somepath/",
|
||||
"name": "entry",
|
||||
"type": "file",
|
||||
}
|
||||
rendered = render_entry(entry)
|
||||
self.assertIn('view/somepath', rendered)
|
||||
def test_GIVEN_path_starts_slash_THEN_view_has_single_slash(self):
|
||||
entry = {
|
||||
"path": "/somepath",
|
||||
"name": "entry",
|
||||
"type": "file",
|
||||
}
|
||||
rendered = render_entry(entry)
|
||||
self.assertIn('view/somepath', rendered)
|
||||
def test_GIVEN_path_ends_slash_THEN_view_has_single_slash(self):
|
||||
entry = {
|
||||
"path": "somepath/",
|
||||
"name": "entry",
|
||||
"type": "file",
|
||||
}
|
||||
rendered = render_entry(entry)
|
||||
self.assertIn('view/somepath', rendered)
|
||||
def test_GIVEN_path_no_slash_THEN_view_has_single_slash(self):
|
||||
entry = {
|
||||
"path": "somepath",
|
||||
"name": "entry",
|
||||
"type": "file",
|
||||
}
|
||||
rendered = render_entry(entry)
|
||||
self.assertIn('view/somepath', rendered)
|
||||
|
||||
from cellxgene_gateway.dir_util import ensure_dir_exists, make_annotations, make_h5ad
|
||||
|
||||
|
||||
class TestMakeH5ad(unittest.TestCase):
|
||||
def test_GIVEN_annotation_dir_THEN_returns_h5ad(self):
|
||||
self.assertEqual(make_h5ad("pbmc_annotations"), "pbmc.h5ad")
|
||||
|
||||
|
||||
class TestMakeAnnotations(unittest.TestCase):
|
||||
def test_GIVEN_h5ad_THEN_returns_annotations(self):
|
||||
self.assertEqual(make_annotations("pbmc.h5ad"), "pbmc_annotations")
|
||||
|
||||
|
||||
class TestMakeAnnotations(unittest.TestCase):
|
||||
def test_GIVEN_h5ad_THEN_returns_annotations(self):
|
||||
self.assertEqual(make_annotations("pbmc.h5ad"), "pbmc_annotations")
|
||||
|
||||
|
||||
class TestEnsureDirExists(unittest.TestCase):
|
||||
@patch("os.path.exists")
|
||||
@patch("os.makedirs")
|
||||
def test_GIVEN_existing_THEN_does_not_call_makedir(self, makedirsMock, existsMock):
|
||||
existsMock.return_value = True
|
||||
ensure_dir_exists("/foo")
|
||||
makedirsMock.assert_not_called()
|
||||
|
||||
@patch("os.path.exists")
|
||||
@patch("os.makedirs")
|
||||
def test_GIVEN_not_existing_THEN_calls_makedir(self, makedirsMock, existsMock):
|
||||
existsMock.return_value = False
|
||||
ensure_dir_exists("/foo")
|
||||
makedirsMock.assert_called_once_with("/foo")
|
||||
|
||||
@@ -1,23 +1,35 @@
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from cellxgene_gateway.extra_scripts import get_extra_scripts
|
||||
|
||||
|
||||
class TestExtraScripts(unittest.TestCase):
|
||||
@patch('cellxgene_gateway.env.extra_scripts', new='["abc","def"]')
|
||||
@patch("cellxgene_gateway.env.extra_scripts", new='["abc","def"]')
|
||||
def test_GIVEN_two_scripts_THEN_returns_two_strings(self):
|
||||
self.assertEqual(get_extra_scripts(), ['abc', 'def'])
|
||||
self.assertEqual(get_extra_scripts(), ["abc", "def"])
|
||||
|
||||
@patch('cellxgene_gateway.env.extra_scripts', new='["abc", "def"]')
|
||||
@patch("cellxgene_gateway.env.extra_scripts", new='["abc", "def"]')
|
||||
def test_GIVEN_two_scripts_space_THEN_returns_two_strings(self):
|
||||
self.assertEqual(get_extra_scripts(), ['abc', 'def'])
|
||||
self.assertEqual(get_extra_scripts(), ["abc", "def"])
|
||||
|
||||
@patch('cellxgene_gateway.env.extra_scripts', new=None)
|
||||
@patch("cellxgene_gateway.env.extra_scripts", new=None)
|
||||
def test_GIVEN_none_THEN_returns_empty_array(self):
|
||||
self.assertEqual(get_extra_scripts(), [])
|
||||
|
||||
@patch('cellxgene_gateway.env.extra_scripts', new='[]')
|
||||
@patch("cellxgene_gateway.env.extra_scripts", new="[]")
|
||||
def test_GIVEN_empty_string_THEN_returns_empty_array(self):
|
||||
self.assertEqual(get_extra_scripts(), [])
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@patch("cellxgene_gateway.env.extra_scripts", new="'asdf'")
|
||||
def test_GIVEN_bare_string_THEN_throws_Exception(self):
|
||||
with self.assertRaises(Exception) as context:
|
||||
self.assertEqual(get_extra_scripts(), [])
|
||||
self.assertEqual(
|
||||
'Error parsing GATEWAY_EXTRA_SCRIPTS, expected JSON array e.g. ["https://example.com/path/to/script.js"]',
|
||||
str(context.exception),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
59
tests/test_filecrawl.py
Normal file
59
tests/test_filecrawl.py
Normal file
@@ -0,0 +1,59 @@
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from cellxgene_gateway.filecrawl import (
|
||||
render_item,
|
||||
render_item_source,
|
||||
render_item_tree,
|
||||
)
|
||||
from cellxgene_gateway.items.file.fileitem import FileItem
|
||||
from cellxgene_gateway.items.file.fileitem_source import FileItemSource
|
||||
from cellxgene_gateway.items.item import ItemTree, ItemType
|
||||
|
||||
source = FileItemSource("/tmp")
|
||||
|
||||
|
||||
class TestRenderEntry(unittest.TestCase):
|
||||
def test_GIVEN_path_both_slash_THEN_view_has_single_slash(self):
|
||||
entry = FileItem(subpath="/somepath/", name="entry", type=ItemType.h5ad)
|
||||
rendered = render_item(entry, source)
|
||||
self.assertIn("view/somepath/entry/'", rendered)
|
||||
|
||||
def test_GIVEN_path_starts_slash_THEN_view_has_single_slash(self):
|
||||
entry = FileItem(subpath="/somepath", name="entry", type=ItemType.h5ad)
|
||||
rendered = render_item(entry, source)
|
||||
self.assertIn("view/somepath/entry/'", rendered)
|
||||
|
||||
def test_GIVEN_path_ends_slash_THEN_view_has_single_slash(self):
|
||||
entry = FileItem(subpath="somepath/", name="entry", type=ItemType.h5ad)
|
||||
rendered = render_item(entry, source)
|
||||
self.assertIn("view/somepath/entry/'", rendered)
|
||||
|
||||
def test_GIVEN_path_no_slash_THEN_view_has_single_slash(self):
|
||||
entry = FileItem(subpath="somepath", name="entry", type=ItemType.h5ad)
|
||||
rendered = render_item(entry, source)
|
||||
self.assertIn("view/somepath/entry/'", rendered)
|
||||
|
||||
|
||||
class TestRenderItemSource(unittest.TestCase):
|
||||
@patch("cellxgene_gateway.items.file.fileitem_source.FileItemSource")
|
||||
def test_GIVEN_some_filter_THEN_includes_filterpart_in_heading(self, item_source):
|
||||
item_source.name = "FakeSource"
|
||||
item_source.list_items.return_value = ItemTree("rootdir", [], [])
|
||||
rendered = render_item_source(item_source, "some_filter")
|
||||
self.assertEqual(
|
||||
rendered,
|
||||
"<h6><a href='/filecrawl.html?source=FakeSource'>FakeSource</a>:some_filter</h6><li><a href='/filecrawl/rootdir?source=FakeSource'>rootdir</a><ul></ul></li>",
|
||||
)
|
||||
|
||||
|
||||
class TestRenderItemTree(unittest.TestCase):
|
||||
@patch("cellxgene_gateway.items.file.fileitem_source.FileItemSource")
|
||||
def test_GIVEN_deep_nested_dirs_THEN_includes_dirs_in_output(self, item_source):
|
||||
item_source.name = "FakeSource"
|
||||
item_tree = ItemTree("foo/bar/baz", [], [])
|
||||
rendered = render_item_tree(item_tree, item_source)
|
||||
self.assertEqual(
|
||||
rendered,
|
||||
"<li><a href='/filecrawl/foo/bar/baz?source=FakeSource'>baz</a><ul></ul></li>",
|
||||
)
|
||||
@@ -1,26 +1,46 @@
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
from cellxgene_gateway.cache_entry import CacheEntry
|
||||
from unittest.mock import patch, seal
|
||||
|
||||
from cellxgene_gateway.backend_cache import BackendCache
|
||||
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
|
||||
|
||||
key = CacheKey(
|
||||
FileItem("/czi/", name="pbmc3k.h5ad", type=ItemType.h5ad),
|
||||
FileItemSource("/tmp", "local"),
|
||||
)
|
||||
|
||||
|
||||
class TestPruneProcessCache(unittest.TestCase):
|
||||
@patch('cellxgene_gateway.util.current_time_stamp', new=lambda:0)
|
||||
@patch('cellxgene_gateway.env.ttl', new='10')
|
||||
@patch('cellxgene_gateway.cache_entry.CacheEntry')
|
||||
@patch('cellxgene_gateway.cache_entry.CacheEntry')
|
||||
@patch("cellxgene_gateway.util.current_time_stamp", new=lambda: 0)
|
||||
@patch("cellxgene_gateway.env.expire_seconds", new=10)
|
||||
@patch("cellxgene_gateway.cache_entry.CacheEntry")
|
||||
@patch("cellxgene_gateway.cache_entry.CacheEntry")
|
||||
def test_GIVEN_one_old_one_new_THEN_prune_old(self, old, new):
|
||||
from cellxgene_gateway.prune_process_cache import PruneProcessCache
|
||||
|
||||
cache = BackendCache()
|
||||
old.timestamp = -100
|
||||
old.foo = 12
|
||||
old.pid = 1
|
||||
old.key = key
|
||||
old.terminate.return_value = None
|
||||
seal(old)
|
||||
new.key = key
|
||||
cache.entry_list.append(old)
|
||||
new.timestamp = -5
|
||||
seal(new)
|
||||
cache.entry_list.append(new)
|
||||
self.assertEqual(len(cache.entry_list), 2)
|
||||
ppc = PruneProcessCache(cache)
|
||||
ppc.prune()
|
||||
self.assertEqual(len(cache.entry_list), 1)
|
||||
self.assertEqual(cache.entry_list[0], new)
|
||||
self.assertEqual(cache.entry_list[0], new)
|
||||
self.assertTrue(old.terminate.called)
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
42
tests/test_subprocess_backend.py
Normal file
42
tests/test_subprocess_backend.py
Normal file
@@ -0,0 +1,42 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user