96 Commits

Author SHA1 Message Date
Alok Saldanha
390fe24ea4 prepare for 0.3.10 release 2022-06-20 21:50:42 -04:00
Alok Saldanha
590565bea2 Merge pull request #69 from Novartis/68_read_from_subprocess
68 read from subprocess
2022-06-20 21:50:28 -04:00
Alok Saldanha
d32a31e855 #68 read process output until it exits 2022-06-20 21:46:19 -04:00
Alok Saldanha
0cd551382e #65 add environment variable to control how long cellxgene processes can remain idle 2022-06-20 21:46:19 -04:00
Alok Saldanha
36c0a4d3d7 #68 add param to set log level 2022-06-20 21:29:19 -04:00
Alok Saldanha
8d8a0a3483 #68 close responses 2022-06-20 21:29:19 -04:00
Alok Saldanha
eaa157079c Merge pull request #67 from Novartis/docker
Remove version pins to upgrade Flask
2022-06-07 12:06:49 -04:00
Alok Saldanha
977c50ce8c #66 switched from mocks to test request context 2022-06-07 07:22:30 -04:00
Alok Saldanha
833cad3bc2 #66 remove version pins 2022-06-07 06:17:11 -04:00
Alok Saldanha
c5f3c68740 Merge pull request #66 from romanhaa/docker
Dockerise cellxgene-gateway
2022-06-07 06:04:13 -04:00
Roman Hillje
d944a31d59 Dockerise cellxgene-gateway 2022-05-20 19:44:46 +02:00
Alok Saldanha
5814cb9943 clarified purpose of refresh query param 2022-03-14 23:17:57 -04:00
Alok Saldanha
9a91cdf795 prepare for 0.3.9 release 2022-03-14 23:12:01 -04:00
Alok Saldanha
25aff5c020 Merge pull request #60 from Novartis/59_s3_caching
#59 add refresh query param to force refresh of S3 cache
2022-03-14 23:08:45 -04:00
Alok Saldanha
6bcb594712 #59 add temporary workaround for jinja 2022-03-14 22:58:06 -04:00
Alok Saldanha
f9ed4c4047 #59 document S3_ENABLE_LISTINGS_CACHE 2022-03-14 22:36:42 -04:00
Alok Saldanha
a9753c4101 #59 change s3 cache variable from S3_DISABLE_LISTINGS_CACHE to S3_ENABLE_LISTINGS_CACHE 2022-03-14 22:36:42 -04:00
Alok Saldanha
fd48920c5b #59 add refresh query param to force refresh of S3 cache 2022-03-14 21:44:05 -04:00
Alok Saldanha
09db93b2b5 Merge pull request #62 from arogozhnikov/patch-1
Force reload of s3 file structure on every request
2022-03-14 20:45:20 -04:00
Alex Rogozhnikov
3e3bd22512 add environment variable S3_DISABLE_LISTINGS_CACHE per Alok's request 2022-03-14 09:59:55 -07:00
Alex Rogozhnikov
893b2f1af1 remove listing cache at the level of fs 2022-03-11 03:20:06 -08:00
Alex Rogozhnikov
757487b772 Force reload folder on every request 2022-03-11 02:43:29 -08:00
Alok Saldanha
87a8dbfa78 #57 Reverted incorrect change to unit test 2021-12-21 13:59:30 -05:00
Alok Saldanha
9c38e48c5c prepare for 0.3.8 release 2021-12-21 12:19:02 -05:00
Alok Saldanha
073f5f945c #57 changed logic to take last path element 2021-12-21 12:05:01 -05:00
Alok Saldanha
9dc4409f1a #57 added failing unit test 2021-12-21 12:03:39 -05:00
Alok Saldanha
551cb46af8 #42 add support for is_authorized hook 2021-11-14 17:28:10 -05:00
Alok Saldanha
620181ae4d prepare for 0.3.7 release 2021-08-12 14:02:26 -04:00
Alok Saldanha
73a7920cc8 add back ip_address endpoint 2021-08-12 13:58:19 -04:00
Alok Saldanha
ed3e999cd1 prepare for 0.3.6 release 2021-07-18 10:42:42 -04:00
Alok Saldanha
2ae2e53863 Pin version of workzeug
This is required by earlier flask-api versions

  File "/home/alokito/code/cellxgene-gateway/cellxgene_gateway/gateway.py", line 25, in <module>
    from flask_api import status
  File "/home/alokito/miniconda3/envs/cellxgene-gateway/lib/python3.7/site-packages/flask_api/__init__.py", line 1, in <module>
    from flask_api.app import FlaskAPI
  File "/home/alokito/miniconda3/envs/cellxgene-gateway/lib/python3.7/site-packages/flask_api/app.py", line 6, in <module>
    from flask_api.request import APIRequest
  File "/home/alokito/miniconda3/envs/cellxgene-gateway/lib/python3.7/site-packages/flask_api/request.py", line 9, in <module>
    from werkzeug._compat import to_unicode
ModuleNotFoundError: No module named 'werkzeug._compat'
2021-07-18 10:32:10 -04:00
Alok Saldanha
fd0e7d9c31 preparing for 0.3.5 release 2021-07-18 09:43:45 -04:00
Alok Saldanha
98ef6efd0c pinned version of flask, to match cellxgene 2021-07-18 09:39:52 -04:00
Alok Saldanha
82e43ff943 preparing for 0.3.4 release 2021-07-18 09:15:27 -04:00
Alok Saldanha
f8a77423eb Merge pull request #51 from Novartis/nested_subdirs
Enable listing nested subdirs
2021-07-18 09:14:16 -04:00
Alok Saldanha
0375a717c9 #50 fixed bug in listing subdirs 2021-07-18 09:04:36 -04:00
Alok Saldanha
4bf57832a0 #50 added failing test for listing subdirs 2021-07-18 08:51:37 -04:00
Alok Saldanha
b7d14dba6a preparing for 0.3.3 release 2021-07-12 20:03:14 -04:00
Alok Saldanha
26286f94b1 Merge pull request #49 from Novartis/fix_cache_pruning
Fix cache pruning
2021-07-12 19:26:53 -04:00
Alok Saldanha
264a324946 #48 fix bug in cache pruning 2021-07-12 19:19:29 -04:00
Alok Saldanha
520069a825 #48 added failing test for cache pruning 2021-07-12 19:12:52 -04:00
Alok Saldanha
3cb0e4d725 added test for is_port_in_use 2021-05-11 07:27:30 -04:00
Alok Saldanha
d2b508e371 Create SECURITY.md 2021-05-06 05:02:36 -04:00
Alok Saldanha
e74f6d01d1 added unit tests for dir_util 2021-04-23 07:14:09 -04:00
Alok Saldanha
7b799d0159 removed unused code 2021-04-23 07:00:53 -04:00
Alok Saldanha
1f0885afdd added pypi badges to Readme.md 2021-04-22 19:16:05 -04:00
Alok Saldanha
a3a1a2d095 Preparing for 0.3.2 release 2021-04-22 19:01:07 -04:00
Alok Saldanha
b4739b0cbb Merge pull request #46 from Novartis/nested_s3
This PR should address https://github.com/Novartis/cellxgene-gateway/issues/45
2021-04-22 07:06:17 -04:00
Alok Saldanha
c29e5c0d92 #45 implemented test__list_items__pass_filter_into_scan_directory 2021-04-22 06:59:08 -04:00
Alok Saldanha
a3e3b6cea8 #45 implemented test__scan_directory__properly_recurses_suburls 2021-04-21 08:21:46 -04:00
Alok Saldanha
687dc31b7a improved error message on invalid extra scripts 2021-04-21 06:21:26 -04:00
Alok Saldanha
68da42ae0e #45 implemented test_GIVEN_some_filter_THEN_includes_filterpart_in_heading 2021-04-20 13:32:28 -04:00
Alok Saldanha
ccf1ba58a8 #45 add extra_scripts to cache_status page 2021-04-20 13:17:37 -04:00
Alok Saldanha
d072034911 #45 pass filter into scan_directory
also include filterpart in heading
2021-04-20 06:03:16 -04:00
Alok Saldanha
1200629d74 #45 handle keys as full subpath rather than last path element 2021-04-20 05:57:48 -04:00
Alok Saldanha
dba13ccacd lowering threshold due to including of itemsource modules 2021-04-05 08:10:55 -04:00
Alok Saldanha
3239aefad4 Preparing for v0.3.1 release 2021-04-05 08:06:25 -04:00
Alok Saldanha
dd727a9469 added missing __init__.py 2021-04-05 08:04:48 -04:00
Alok Saldanha
ae5cf09d04 Preparing for 0.3.0 release 2021-04-05 07:55:20 -04:00
Alok Saldanha
ae35d24d80 only set wsgi.url_scheme when EXTERNAL_PROTOCOL is set 2021-04-05 07:55:20 -04:00
Alokito
ad3517f002 Create codeql-analysis.yml 2021-04-05 07:08:34 -04:00
Alok Saldanha
2cefc6d741 added token and path to codecov upload 2021-04-05 06:53:44 -04:00
Alok Saldanha
1586c9a3e8 added code coverage badge 2021-04-04 20:03:02 -04:00
Alok Saldanha
f16bd5e2b9 added test for SubprocessBackend.launch 2021-04-04 19:48:13 -04:00
Alok Saldanha
10105c43a8 added support for code coverage 2021-04-04 14:55:13 -04:00
Alokito
638923fb43 Merge pull request #44 from Novartis/itemsource
Itemsource
2021-04-03 07:15:35 -04:00
Alok Saldanha
169da934b1 updated annotation.js comment 2021-04-03 07:12:50 -04:00
Alok Saldanha
33331c9198 Template has correct relaunch_url. 2021-04-03 07:08:20 -04:00
Alok Saldanha
d2d22cecaa updated tests, formatting 2021-04-02 11:52:49 -04:00
Alok Saldanha
5c488d8d5e only include source path element in url when multiple sources 2021-04-02 11:40:11 -04:00
Alok Saldanha
b1ae9e8ce8 ensure s3 bucket does not include trailing slash 2021-04-02 11:37:11 -04:00
Alok Saldanha
76c1d9e80c removed .csv suffix from annotation name 2021-03-29 07:28:16 -04:00
Alok Saldanha
586dc6623a ensure annotation directory for new annotations 2021-03-29 07:03:05 -04:00
Alok Saldanha
76f3939fdd rebased "Introduction of ItemSource interface" patch 2021-03-28 22:05:08 -04:00
Alok Saldanha
5404a8b0f3 removed trailing slash from render_annotations 2021-03-28 21:59:03 -04:00
Alok Saldanha
6f2b372d32 Preparing for 0.2.3 release 2021-02-28 09:55:26 -05:00
Alokito
25755d3f69 Merge pull request #41 from Novartis/grst_master
Grst master
2021-02-22 08:59:05 -05:00
Alok Saldanha
893092f199 fixed unit tests 2021-02-13 17:30:21 -05:00
Alok Saldanha
d3ee04b6e5 blacken 2021-02-13 17:02:55 -05:00
Alok Saldanha
f1f4b0c0ca added environment variables for ProxyFix 2021-02-13 16:42:34 -05:00
Alok Saldanha
01dccef7db added back trailing slash to url_for change 2021-02-13 16:41:32 -05:00
Gregor Sturm
9c17e2ff32 Fix redirect in cache_entry 2021-01-18 22:08:25 +01:00
Gregor Sturm
fbc18fb636 Add ProxyFix to gateway.py 2021-01-18 20:01:53 +01:00
Gregor Sturm
8c5a635de9 Use url_for in all templates 2021-01-18 19:47:18 +01:00
Gregor Sturm
94062c2d64 apply proxy fix 2021-01-18 18:19:25 +01:00
Gregor Sturm
068e8f7633 Fix url_for 2021-01-18 18:09:45 +01:00
Gregor Sturm
16b54f9409 Use url_for to generate URLs 2021-01-18 16:59:15 +01:00
Alok Saldanha
942410bb44 added instructions on pre-commit installation to README.md 2020-12-31 17:12:11 -05:00
Alokito
0d084a405e Merge pull request #38 from Novartis/feature/action_push
Feature/action push
2020-12-31 16:33:30 -05:00
Alok Saldanha
49d679e779 try evaling the bash hook
per https://github.com/conda/conda/issues/7980
2020-12-31 13:23:25 -05:00
Alok Saldanha
5e6faa4b02 run pr checks on push 2020-12-31 13:06:56 -05:00
Alokito
9fe846c786 Merge pull request #37 from ericmjl/master
Migrated PR checks to GitHub actions
2020-12-28 11:38:26 -05:00
Eric Ma
1c7907aabd Change file extension 2020-12-27 21:26:32 -05:00
Eric Ma
30d2b07b1a Migrated PR checks to GitHub actions 2020-12-27 21:10:07 -05:00
Alokito
21ff56ea8b Merge pull request #35 from dfeinzeig/fix/redirect
add missing trailing slash in effort to avoid whatever is redirecting
2020-10-28 07:39:06 -04:00
David Feinzeig
0b866a46ec add missing trailing slash in effort to avoid whatever is redirecting 2020-10-23 17:41:05 -04:00
51 changed files with 993 additions and 297 deletions

13
.coveragerc Normal file
View 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
View 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
View 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

1
.gitignore vendored
View File

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

View File

@@ -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

View File

@@ -1,3 +1,59 @@
# 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)

8
Dockerfile Normal file
View 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"]

View File

@@ -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.
[![codecov](https://codecov.io/gh/Novartis/cellxgene-gateway/branch/master/graph/badge.svg?token=ndEFSzRKJn)](https://codecov.io/gh/Novartis/cellxgene-gateway) [![PyPI](https://img.shields.io/pypi/v/cellxgene-gateway)](https://pypi.org/project/cellxgene-gateway/) [![PyPI - Downloads](https://img.shields.io/pypi/dm/cellxgene-gateway)](https://pypistats.org/packages/cellxgene-gateway)
# Running locally
## Prequisites
@@ -71,12 +73,49 @@ Optional environment variables:
* `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_ANNOTATIONS` - Set to `true` or to `1` to enable cellxgene annotations.
* `GATEWAY_ENABLE_BACKED_MODE` - Set to `true` or to `1` to load AnnData in file-backed mode. This saves memory and speeds up launch time but may reduce overall performance.
* `GATEWAY_ENABLE_BACKED_MODE` - Set to `true` or to `1` to load AnnData in file-backed mode. This saves memory and speeds up launch time but may reduce overall performance.
* `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:
@@ -117,6 +156,14 @@ 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
[![Build Status](https://travis-ci.org/Novartis/cellxgene-gateway.svg?branch=master)](https://travis-ci.org/Novartis/cellxgene-gateway)
@@ -125,6 +172,12 @@ For convenience, the code repo includes a `run.sh.example` shell script to run t
python -m unittest discover tests
```
## Code Coverage
```bash
coverage run -m unittest discover tests
coverage html
```
## Running Linters
pip install isort flake8 black

15
SECURITY.md Normal file
View 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
View File

@@ -7,4 +7,4 @@
# 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.2.2"
__version__ = "0.3.10"

View File

@@ -9,6 +9,7 @@
import time
from threading import Thread
from typing import List
from flask_api import status
@@ -17,7 +18,6 @@ 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
from typing import List
process_backend = SubprocessBackend()

View File

@@ -8,19 +8,21 @@
# the specific language governing permissions and limitations under the License.
import datetime
import logging
import urllib.parse
import re
from enum import Enum
import psutil
from flask import make_response, render_template, request
from flask.wrappers import Response
from requests import get, post, put
import re
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"
@@ -110,9 +112,7 @@ class CacheEntry:
except psutil.NoSuchProcess:
pass
logging.getLogger("cellxgene_gateway").info(
f"terminated {terminated}"
)
logger.info(f"terminated {terminated}")
self.status = CacheEntryStatus.terminated
def rewrite_text_content(self, cellxgene_content):
@@ -120,30 +120,22 @@ class CacheEntry:
gateway_content = (
re.sub(
'(="|\()/static/',
f"\\1{self.gateway_basepath()}static/",
f"\\1{self.key.gateway_basepath()}static/",
cellxgene_content,
)
.replace("http://fonts.gstatic.com", "https://fonts.gstatic.com")
.replace(self.cellxgene_basepath(), self.gateway_basepath())
.replace(self.cellxgene_basepath(), self.key.gateway_basepath())
)
return gateway_content
def gateway_basepath(self):
source_path = (
f"/source/{urllib.parse.quote_plus(self.source_name)}"
if self.source_name
else ""
)
return f"{env.external_protocol}://{env.external_host}{source_path}/view/{self.key.descriptor}/"
def cellxgene_basepath(self):
return f"http://127.0.0.1:{self.port}"
def serve_content(self, path):
gateway_basepath = self.gateway_basepath()
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 = make_response(f"Redirect to {gateway_basepath}\n", 302)
r.headers["location"] = gateway_basepath + querystring()
return r
elif self.status == CacheEntryStatus.loading:
@@ -178,40 +170,43 @@ class CacheEntry:
full_path = self.cellxgene_basepath() + subpath + querystring()
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
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
resp_headers = {}
for h in copy_headers:
if h in cellxgene_response.headers:
resp_headers[h] = cellxgene_response.headers[h]
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,
)
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

View File

@@ -15,6 +15,7 @@
# 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
@@ -38,6 +39,16 @@ class CacheKey:
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

View File

@@ -14,44 +14,6 @@ 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)
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)
annotations_suffix = "_annotations"
h5ad_suffix = ".h5ad"
@@ -62,3 +24,8 @@ def make_h5ad(el):
def make_annotations(el):
return el[:-5] + annotations_suffix
def ensure_dir_exists(file_path):
if not os.path.exists(file_path):
os.makedirs(file_path)

View File

@@ -9,10 +9,9 @@
import logging
import os
import socket
cellxgene_location = os.environ.get("CELLXGENE_LOCATION")
cellxgene_data = os.environ.get("CELLXGENE_DATA")
cellxgene_data = os.environ.get("CELLXGENE_DATA", "")
cellxgene_args = os.environ.get("CELLXGENE_ARGS", None)
gateway_port = int(os.environ.get("GATEWAY_PORT", "5005"))
external_host = os.environ.get(
@@ -20,39 +19,50 @@ external_host = os.environ.get(
os.environ.get("GATEWAY_HOST", f"localhost:{gateway_port}"),
)
external_protocol = os.environ.get(
"EXTERNAL_PROTOCOL", os.environ.get("GATEWAY_PROTOCOL", "http")
"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_annotations = os.environ.get(
"GATEWAY_ENABLE_ANNOTATIONS", ""
).lower() in [
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 [
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,
}
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_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,
}

View File

@@ -8,6 +8,7 @@
# the specific language governing permissions and limitations under the License.
from json import loads
from json.decoder import JSONDecodeError
from cellxgene_gateway import env
@@ -17,4 +18,9 @@ def get_extra_scripts():
# ['https://www.googletagmanager.com/gtag/js?id=UA-123456-2',
# 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

View File

@@ -10,14 +10,20 @@
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):
subpath = f"/source/{urllib.parse.quote_plus(item_source.name)}/view/"
new_annotation = f"<a class='new' href='{subpath}{item_source.get_annotations_subpath(item)}'>new</a>"
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='{subpath}{a.descriptor}/'>{a.name}</a>"
f"<a href='{CacheKey(item, item_source, a).view_url}/'>{a.name}</a>"
for a in item.annotations
]
)
@@ -25,13 +31,11 @@ def render_annotations(item, item_source):
if item.annotations
else ""
)
return " | annotations: " + annotations + new_annotation
def render_item(item, item_source):
url = f"/source/{urllib.parse.quote_plus(item_source.name)}/view/{item.descriptor}/"
item_string = f"<li> <a href='{ url }'>{item.name}</a> {render_annotations(item, item_source)}</li>"
item_string = f"<li> <a href='{ CacheKey(item, item_source).view_url }/'>{item.name}</a> {render_annotations(item, item_source)}</li>"
return item_string
@@ -42,9 +46,7 @@ def render_item_tree(item_tree, item_source):
else ""
)
branches = (
"\n".join(
[render_item_tree(b, item_source) for b in item_tree.branches]
)
"\n".join([render_item_tree(b, item_source) for b in item_tree.branches])
if item_tree.branches
else ""
)
@@ -52,11 +54,7 @@ def render_item_tree(item_tree, item_source):
if item_tree.descriptor:
descriptor = item_tree.descriptor.lstrip("/")
url = f"/filecrawl/{descriptor}?source={item_source.name}"
name = (
descriptor.rsplit("/")[1]
if descriptor.find("/") >= 0
else descriptor
)
name = descriptor.rsplit("/", 1)[-1]
return f"<li><a href='{url}'>{name}</a>{html}</li>"
else:
return html
@@ -64,5 +62,6 @@ def render_item_tree(item_tree, item_source):
def render_item_source(item_source, filter=None):
item_tree = item_source.list_items(filter)
heading = f"<h6><a href='/filecrawl.html?source={urllib.parse.quote_plus(item_source.name)}'>{item_source.name}</a></h6>"
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)

View File

@@ -7,9 +7,27 @@
# 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
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)

View File

@@ -22,20 +22,18 @@ from flask import (
send_from_directory,
url_for,
)
from flask_api import status
from werkzeug.utils 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, is_subdir
from cellxgene_gateway.extra_scripts import get_extra_scripts
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
from cellxgene_gateway.cache_key import CacheKey
app = Flask(__name__)
@@ -45,16 +43,39 @@ default_item_source = None
def _force_https(app):
def wrapper(environ, start_response):
environ["wsgi.url_scheme"] = env.external_protocol
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.external_protocol}://{env.external_host}"
@app.errorhandler(CellxgeneException)
@@ -90,7 +111,7 @@ def handle_invalid_process(error):
http_status=error.http_status,
stdout=error.stdout,
stderr=error.stderr,
dataset=error.key.h5ad_item.descriptor,
relaunch_url=error.key.relaunch_url(),
annotation_file=error.key.annotation_descriptor,
),
error.http_status,
@@ -142,10 +163,7 @@ def filecrawl(path=None):
path=path,
)
)
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"
set_no_cache(resp)
return resp
@@ -194,14 +212,21 @@ def do_view(path, source_name=None):
match.status == CacheEntryStatus.loaded
or match.status == CacheEntryStatus.loading
):
return match.serve_content(path)
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"])
@@ -225,15 +250,14 @@ def do_GET_status_json():
@app.route("/relaunch/<path:path>", methods=["GET"])
def do_relaunch(path):
source_name = request.args.get("source") or default_item_source.name
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()
qs = request.query_string.decode()
return redirect(
url_for("do_view", path=path) + (f"?{qs}" if len(qs) > 0 else ""),
key.view_url,
code=302,
)
@@ -249,6 +273,12 @@ def do_terminate(path):
return redirect(url_for("do_GET_status"), code=302)
@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):
@@ -269,7 +299,7 @@ def launch():
def main():
logging.basicConfig(
level=logging.INFO,
level=env.log_level,
format="%(asctime)s:%(name)s:%(levelname)s:%(message)s",
)
cellxgene_data = os.environ.get("CELLXGENE_DATA", None)
@@ -287,6 +317,7 @@ def main():
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()

View File

View File

View File

@@ -18,10 +18,11 @@ class FileItem(Item):
The Item superclass expects a 'name' and 'type'.
"""
def __init__(self, subpath: str, *args, **kwargs):
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).strip("/")
return os.path.join(self.subpath, self.name + self.ext).strip("/")

View File

@@ -51,7 +51,7 @@ class FileItemSource(ItemSource):
return self.convert_h5ad_path_to_annotation(item.descriptor)
def list_items(self, filter: str = None) -> ItemTree:
item_tree = self.scan_directory()
item_tree = self.scan_directory("" if filter is None else filter)
"""def get_items(dir):
if dir.branches:
@@ -67,9 +67,7 @@ class FileItemSource(ItemSource):
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."
)
raise Exception(f"Path for local files '{base_path}' does not exist.")
filepath_map = dict(
(filepath, os.path.join(base_path, filepath))
@@ -95,14 +93,12 @@ class FileItemSource(ItemSource):
]
items = [
self.make_fileitem_from_path(filename, subpath)
for filename in h5ad_paths
self.make_fileitem_from_path(filename, subpath) for filename in h5ad_paths
]
branches = None
if len(subdirs) > 0:
branches = [
self.scan_directory(os.path.join(subpath, subdir))
for subdir in subdirs
self.scan_directory(os.path.join(subpath, subdir)) for subdir in subdirs
]
return ItemTree(subpath, items, branches)
@@ -125,17 +121,19 @@ class FileItemSource(ItemSource):
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
)
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)
@@ -155,9 +153,16 @@ class FileItemSource(ItemSource):
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=filename,
name=name,
ext=ext,
type=ItemType.annotation if is_annotation else ItemType.h5ad,
)
@@ -172,14 +177,10 @@ class FileItemSource(ItemSource):
annotations_fullpath = self.full_path(annotations_subpath)
if os.path.isdir(annotations_fullpath):
return [
self.make_fileitem_from_path(
annotation, annotations_subpath, True
)
self.make_fileitem_from_path(annotation, annotations_subpath, True)
for annotation in sorted(os.listdir(annotations_fullpath))
if annotation.endswith(self.annotation_file_suffix)
and os.path.isfile(
os.path.join(annotations_fullpath, annotation)
)
and os.path.isfile(os.path.join(annotations_fullpath, annotation))
]
else:
return None

View File

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

View File

@@ -40,6 +40,10 @@ class ItemSource(ABC):
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')

View File

View File

@@ -7,13 +7,21 @@
# 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
from os.path import join, dirname, basename
from cellxgene_gateway import dir_util
import flask
import s3fs
from cellxgene_gateway.items.s3.s3item import S3Item
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):
@@ -26,14 +34,24 @@ class S3ItemSource(ItemSource):
annotation_file_suffix=".csv",
):
self._name = name
self.s3 = s3fs.S3FileSystem()
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, path):
return "s3://" + join(self.bucket, path)
def url(self, key):
return "s3://" + self.bucket + "/" + key
def remove_bucket(self, filepath):
return filepath[len(self.bucket) :].lstrip("/")
@property
def name(self):
@@ -55,51 +73,51 @@ class S3ItemSource(ItemSource):
return self.convert_h5ad_key_to_annotation(item.descriptor)
def list_items(self, filter: str = None) -> ItemTree:
item_tree = self.scan_directory()
item_tree = self.scan_directory("" if filter is None else filter)
return item_tree
def scan_directory(self, subpath="") -> dict:
url = self.url(subpath)
@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(
(filepath[len(self.bucket) :], "s3://" + filepath)
for filepath in sorted(self.s3.ls(url))
(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_paths
and self.convert_annotation_key_to_h5ad(dir_s3key) in h5ad_keys
)
h5ad_paths = [
h5ad_keys = [
filepath
for filepath, item_url in s3key_map.items()
if self.is_h5ad_url(item_url)
]
subdirs = [
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(filename, join(subpath, filename))
for filename in h5ad_paths
]
items = [self.make_s3item_from_key(basename(key), key) for key in h5ad_keys]
branches = None
if len(subdirs) > 0:
branches = [
self.scan_directory(join(subpath, subdir))
for subdir in subdirs
]
if len(subdir_keys) > 0:
branches = [self.scan_directory(key) for key in subdir_keys]
return ItemTree(subpath, items, branches)
return ItemTree(directory_key, items, branches)
def create_annotation(self, item: S3Item, name: str) -> S3Item:
annotation = self.make_s3item_from_key(
@@ -111,6 +129,9 @@ class S3ItemSource(ItemSource):
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):
@@ -119,9 +140,7 @@ class S3ItemSource(ItemSource):
def lookup(self, indescriptor: str) -> LookupResult:
descriptor = indescriptor.strip("/")
if descriptor.endswith(self.annotation_file_suffix):
annotation_item = self.shallowitem_from_descriptor(
descriptor, True
)
annotation_item = self.shallowitem_from_descriptor(descriptor, True)
if not self.s3.exists(self.url(annotation_item.s3key)):
with self.s3.open(self.url(annotation_item.s3key), "w") as f:
f.write("")
@@ -161,11 +180,13 @@ class S3ItemSource(ItemSource):
if self.s3.isdir(annotations_fullpath):
return [
self.make_s3item_from_key(
annotation, join(annotations_subpath, annotation), True
basename(annotation), self.remove_bucket(annotation), True
)
for annotation in sorted(
self.s3.ls(annotations_fullpath, refresh=self.refresh)
)
for annotation in sorted(self.s3.ls(annotations_fullpath))
if annotation.endswith(self.annotation_file_suffix)
and self.s3.isfile(join(annotations_fullpath, annotation))
and self.s3.isfile("s3://" + annotation)
]
else:
return None

View File

@@ -1,41 +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.cache_key import CacheKey
from cellxgene_gateway.cellxgene_exception import CellxgeneException
from cellxgene_gateway.dir_util import make_h5ad
def validate_exists(file_path):
if not os.path.exists(file_path):
raise CellxgeneException(
"File does not exist: " + file_path, status.HTTP_400_BAD_REQUEST
)
def validate_is_file(file_path):
validate_exists(file_path)
if not os.path.isfile(file_path):
raise CellxgeneException(
"Path is not file: " + file_path, status.HTTP_400_BAD_REQUEST
)
return
def validate_is_dir(file_path):
validate_exists(file_path)
if not os.path.isdir(file_path):
raise CellxgeneException(
"Path is not dir: " + file_path, status.HTTP_400_BAD_REQUEST
)
return

View File

@@ -10,14 +10,15 @@
import logging
import time
from cellxgene_gateway.env import ttl
from cellxgene_gateway.util import current_time_stamp
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,26 +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_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}"
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.key.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})"
"failed to prune process {process.pid} ({process.key.descriptor})"
)

View File

@@ -1,5 +1,5 @@
// neandertal javascript
// TODO: rewrite this --
// 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) => {

View File

@@ -14,26 +14,20 @@ 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 (
enable_annotations,
enable_backed_mode,
cellxgene_args,
)
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, annotation_file_path
):
def create_cmd(self, cellxgene_loc, file_path, port, scripts, annotation_file_path):
if enable_annotations and not annotation_file_path is None:
if annotation_file_path == "":
extra_args = (
f" --annotations-dir {make_annotations(file_path)}"
)
extra_args = f" --annotations-dir {make_annotations(file_path)}"
else:
extra_args = f" --annotations-file {annotation_file_path}"
else:
@@ -63,7 +57,7 @@ class SubprocessBackend:
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
)
@@ -92,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}")

View File

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

View File

@@ -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>

View File

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

View File

@@ -35,12 +35,12 @@
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>

View File

@@ -35,11 +35,11 @@
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>

View File

@@ -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>

View File

@@ -2,12 +2,17 @@ name: cellxgene-gateway
channels:
- conda-forge
dependencies:
- python=3.7
- python=3.9
- requests
- flask
- psutil
- black
- twine
- isort
- coverage
- pip
- pip:
- pre_commit
- flask-api
- cellxgene>=0.15
- werkzeug
- cellxgene

View File

@@ -1,5 +1,6 @@
cellxgene>=0.15
cellxgene
flask
flask_api
flask-api
werkzeug
psutil
requests

0
tests/items/__init__.py Normal file
View File

View File

View 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")

View File

View 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("")

View 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)

View File

@@ -1,33 +1,62 @@
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.items.item import ItemType
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/", "pbmc3k.h5ad", ItemType.h5ad),
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(http://localhost:5005/source/local/view/czi/pbmc3k.h5ad/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="http://localhost:5005/source/local/view/czi/pbmc3k.h5ad/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)

35
tests/test_dir_util.py Normal file
View File

@@ -0,0 +1,35 @@
import unittest
from unittest.mock import MagicMock, patch
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")

View File

@@ -21,6 +21,15 @@ class TestExtraScripts(unittest.TestCase):
def test_GIVEN_empty_string_THEN_returns_empty_array(self):
self.assertEqual(get_extra_scripts(), [])
@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()

View File

@@ -1,19 +1,21 @@
import unittest
from unittest.mock import MagicMock, patch
from cellxgene_gateway.filecrawl import render_item
from cellxgene_gateway.items.item import ItemType
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
)
entry = FileItem(subpath="/somepath/", name="entry", type=ItemType.h5ad)
rendered = render_item(entry, source)
self.assertIn("view/somepath/entry/'", rendered)
@@ -31,3 +33,27 @@ class TestRenderEntry(unittest.TestCase):
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>",
)

View File

@@ -1,13 +1,21 @@
import unittest
from unittest.mock import MagicMock, patch
from unittest.mock import patch, seal
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
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.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):
@@ -15,14 +23,23 @@ class TestPruneProcessCache(unittest.TestCase):
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__":

View 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)