Compare commits

...
71 Commits
Author SHA1 Message Date
Alok Saldanha 05a95c2ca9 Prepare 0.4.0 release 2024-03-10 09:33:50 -04:00
Alok Saldanha 5d29153544 #87 remove version pins for markupsafe, flask and werkzeug
also remove dependency on flask-api
2024-03-10 09:31:39 -04:00
Alok Saldanha 6a2bc409db Prepare for 0.3.12 release 2024-03-03 07:45:47 -05:00
Alok Saldanha fa72481b66 Merge remote-tracking branch 'origin/dependabot/pip/werkzeug-2.3.8' 2024-03-03 07:33:03 -05:00
Alok Saldanha 3d0166904b Merge pull request #90 from Novartis/dependabot/pip/flask-2.2.5
Bump flask from 2.2.2 to 2.2.5
2024-03-03 07:31:42 -05:00
Alok Saldanha 4e63ff95a8 #87 blacken 2024-03-02 12:25:21 -05:00
Alok Saldanha c1111e2cb4 #87 patch enable annotations 2024-03-02 12:23:11 -05:00
Alok Saldanha c4b9084286 #87 Fix test 2024-03-02 12:08:57 -05:00
dependabot[bot] 0282da03c8 Bump werkzeug from 2.3.0 to 2.3.8
Bumps [werkzeug](https://github.com/pallets/werkzeug) from 2.3.0 to 2.3.8.
- [Release notes](https://github.com/pallets/werkzeug/releases)
- [Changelog](https://github.com/pallets/werkzeug/blob/main/CHANGES.rst)
- [Commits](https://github.com/pallets/werkzeug/compare/2.3.0...2.3.8)

---
updated-dependencies:
- dependency-name: werkzeug
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-02 16:56:15 +00:00
dependabot[bot] bec74bec45 Bump flask from 2.2.2 to 2.2.5
Bumps [flask](https://github.com/pallets/flask) from 2.2.2 to 2.2.5.
- [Release notes](https://github.com/pallets/flask/releases)
- [Changelog](https://github.com/pallets/flask/blob/main/CHANGES.rst)
- [Commits](https://github.com/pallets/flask/compare/2.2.2...2.2.5)

---
updated-dependencies:
- dependency-name: flask
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-02 16:56:15 +00:00
Alok Saldanha 35c8e8180c #87 temporarily pin versions 2024-03-02 11:55:07 -05:00
Alok Saldanha 0000a60eb0 #73 hide annotation links when disabled 2024-03-02 11:51:20 -05:00
Alok Saldanha 1e02e0abb8 Merge pull request #74 from Novartis/73_reorder_filecrawl
#73 moved new annotation link to front
2024-03-02 11:44:49 -05:00
Alok Saldanha 66cca86b51 Merge remote-tracking branch 'ghall/just_gene_sets' 2024-03-02 11:09:00 -05:00
Alok Saldanha f046e7c5d2 Merge pull request #88 from Mye-InfoBank/master
Fix dockerfile installation problems
2024-02-24 09:22:49 -05:00
Nico Trummer a8f6e45f34 Implement pip upgrade to Dockerfile 2024-02-21 09:47:46 +01:00
george-hall-ucl 19caa6cb80 Sorry -- forgot to lint 2023-08-08 16:04:03 +01:00
george-hall-ucl 56bd079024 Save gene sets without cell annotations
This fixes a bug whereby new gene_sets csv files created without
accompanying cell-level annotations could not be detected by the
filecrawler.
2023-08-08 15:49:57 +01:00
Alok Saldanha 9d10932b06 prepare for 0.3.11 release 2023-07-09 19:27:32 -04:00
Alok Saldanha c7c156b4cf Merge pull request #77 from aeisenbarth/filter-empty-folders
Filter directories without h5ad files
2023-07-09 07:04:06 -06:00
Alok Saldanha 624d1f8567 #78 Revert "Rename argument "filter" to "subpath""
This reverts commit fdd6cca297.
2023-07-09 08:25:27 -04:00
Alok Saldanha 79c3f588b6 Merge pull request #80 from Novartis/79_add_docker_example
79 add docker example
2023-07-09 06:05:50 -06:00
Alok Saldanha d8fd07572c Merge pull request #83 from Novartis/81_gene_set_support
gene set support
2023-07-09 06:03:33 -06:00
Alok Saldanha 64d636a1c5 #81 added unit test for gene sets 2023-07-09 07:46:01 -04:00
Alok Saldanha 7b314d4457 #81 switch to latest ubuntu 2023-07-06 17:15:00 -06:00
Alok Saldanha 2754bc1ef1 #81 Combined GATEWAY_ENABLE_ANNOTATIONS and GATEWAY_ENABLE_GENE_SETS flags 2023-07-06 08:53:19 -06:00
Alok Saldanha 5a650334df #81 moved gene set check into fileitem_source 2023-07-06 08:18:50 -06:00
george-hall-ucl 81c8ce4219 #81 Add support for gene sets
This adds the flag `GATEWAY_ENABLE_GENE_SETS` to enable support for gene
sets.  To simplify implementation, activating this flag also activates
`GATEWAY_ENABLE_ANNOTATIONS`.  The gene sets are saved in a file that
has the same name as the annotations `csv` but with `_gene_sets`
appended to the file name (before the extension).  This file is hidden
in filecrawler, and the gene sets are loaded when the associated
annotations file is loaded.

If the annotations file is missing, then an Exception is raised.

I have updated one unit test to make it expect
`--disable-gene-sets-save` in the default case (i.e. if
`GATEWAY_ENABLE_ANNOTATIONS = 0`).  All units tests pass.

I have updated the README to document `GATEWAY_ENABLE_GENE_SETS`.
2023-07-06 07:59:12 -06:00
Alok Saldanha f296efcc55 #79 added cellxgene-data directory so it actually works 2022-12-21 16:02:50 -05:00
Alok Saldanha facfb27d5c #79 add simple example to customize cellxgene-gateway ui 2022-12-21 15:42:13 -05:00
Andreas Eisenbarth 6607b15085 Exclude directories having no h5ad files 2022-10-12 17:31:22 +02:00
Andreas Eisenbarth 0e92b73347 Add test case for dirs without h5ad 2022-10-12 17:31:22 +02:00
Andreas Eisenbarth 88b9b815c0 Adjust test case for dirs with h5ad 2022-10-12 17:31:22 +02:00
Andreas Eisenbarth 6ef82b36f1 For running individual tests, make sure flask_util.view_url is callable 2022-10-12 16:36:46 +02:00
Andreas Eisenbarth fdd6cca297 Rename argument "filter" to "subpath" 2022-10-12 13:40:39 +02:00
Alok Saldanha a00403c60e #73 moved new annotation link to front 2022-08-21 08:15:09 -04:00
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
27 changed files with 484 additions and 108 deletions
+2 -2
View File
@@ -6,7 +6,7 @@ on: [push, pull_request]
jobs: jobs:
black: black:
runs-on: ubuntu-18.04 runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
name: Checkout repository name: Checkout repository
@@ -25,7 +25,7 @@ jobs:
black . --check black . --check
# This job is copied over from `deploy.yaml` # This job is copied over from `deploy.yaml`
run-tests: run-tests:
runs-on: ubuntu-18.04 runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
-1
View File
@@ -8,7 +8,6 @@ repos:
types: [python] types: [python]
stages: [commit] stages: [commit]
- id: black - id: black
language_version: python3.6+
name: black name: black
language: system language: system
entry: black entry: black
+50
View File
@@ -1,3 +1,53 @@
# 0.4.0
* Removed dependency on flask-api
* Updated dependencies (python 3.11, numpy, unpinned flask, werkzeug)
# 0.3.12
* #81 List gene set annotations when cell annotations not present
* #86 Upgrade pip within docker image
* #73 Moved new link to front
* #87 Temporarily pin versions of werkzeug and flask
# 0.3.11
* #81 added support for gene sets
* #79 added example for cellxgene-gateway customized docker image
* #78 prune directories that do not contain h5ad files
# 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 # 0.3.3
* Fixed bug #48 affecting cache pruning * Fixed bug #48 affecting cache pruning
+9
View File
@@ -0,0 +1,9 @@
FROM python:3.11
RUN pip install --upgrade pip
RUN pip install "cellxgene-gateway>=0.4"
ENV CELLXGENE_DATA=/cellxgene-data
ENV CELLXGENE_LOCATION=/usr/local/bin/cellxgene
CMD ["cellxgene-gateway"]
+60 -1
View File
@@ -73,9 +73,12 @@ 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" * `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_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_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_EXTRA_SCRIPTS` - JSON array of script paths, will be embedded into each page and forwarded with `--scripts` to cellxgene server
* `GATEWAY_ENABLE_ANNOTATIONS` - Set to `true` or to `1` to enable cellxgene annotations. * `GATEWAY_ENABLE_ANNOTATIONS` - Set to `true` or to `1` to enable cellxgene annotations and gene sets.
* `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. 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_FOR` - Number of upstream proxies setting X-Forwarded-For
@@ -86,6 +89,33 @@ If any of the following optional variables are set, [ProxyFix](https://werkzeug.
The defaults should be fine if you set up a venv and cellxgene_data folder as above. The defaults should be fine if you set up a venv and cellxgene_data folder as above.
## 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 ../cellxgene_data:/cellxgene-data \
-e GATEWAY_PORT=8080 \
-p 8080:8080 \
cellxgene-gateway
```
# Customization # Customization
The current paradigm for customization is to modify files during a build or deployment phase: The current paradigm for customization is to modify files during a build or deployment phase:
@@ -161,6 +191,35 @@ black .
If you need help for any reason, please make a github ticket. One of the contributors should help you out. If you need help for any reason, please make a github ticket. One of the contributors should help you out.
# Releasing New Versions
## How to prepare for release
- Update Changelog.md and version number in __init__.py
- Cut a release on github
- Go to your project homepage on GitHub
- On right side, you will see [Releases](https://github.com/Novartis/cellxgene-gateway/releases) link. Click on it.
- Click on Draft a new release
- Fill in all the details
- Tag version should be the version number of your package release
- Release Title can be anything you want, but we use v0.3.11 (the same as the tag to be created on publish)
- Description should be changelog
- Click Publish release at the bottom of the page
- Now under Releases you can view all of your releases.
- Copy the download link (tar.gz) and save it somewhere
## How to publish to PyPI
Make sure your `.pypirc` is set up for testpypi and pypi index servers.
```bash
rm -rf dist
python setup.py sdist bdist_wheel
python -m twine upload --repository testpypi dist/*
python -m twine upload dist/*
```
# Contributors # Contributors
* Niket Patel - https://github.com/NiketPatel9 * Niket Patel - https://github.com/NiketPatel9
+1 -1
View File
@@ -7,4 +7,4 @@
# OR CONDITIONS OF ANY KIND, either express or implied. See the License for # OR CONDITIONS OF ANY KIND, either express or implied. See the License for
# the specific language governing permissions and limitations under the License. # the specific language governing permissions and limitations under the License.
__version__ = "0.3.3" __version__ = "0.4.0"
+3 -4
View File
@@ -8,11 +8,10 @@
# the specific language governing permissions and limitations under the License. # the specific language governing permissions and limitations under the License.
import time import time
from http import HTTPStatus
from threading import Thread from threading import Thread
from typing import List from typing import List
from flask_api import status
from cellxgene_gateway import env from cellxgene_gateway import env
from cellxgene_gateway.cache_entry import CacheEntry, CacheEntryStatus from cellxgene_gateway.cache_entry import CacheEntry, CacheEntryStatus
from cellxgene_gateway.cache_key import CacheKey from cellxgene_gateway.cache_key import CacheKey
@@ -53,7 +52,7 @@ class BackendCache:
return matches[0] return matches[0]
else: else:
raise CellxgeneException( raise CellxgeneException(
status.HTTP_500_INTERNAL_SERVER_ERROR, HTTPStatus.INTERNAL_SERVER_ERROR,
"Found " + str(len(matches)) + " for " + path, "Found " + str(len(matches)) + " for " + path,
) )
@@ -71,7 +70,7 @@ class BackendCache:
return matches[0] return matches[0]
else: else:
raise CellxgeneException( raise CellxgeneException(
status.HTTP_500_INTERNAL_SERVER_ERROR, HTTPStatus.INTERNAL_SERVER_ERROR,
"Found " + str(len(matches)) + " for " + key.dataset, "Found " + str(len(matches)) + " for " + key.dataset,
) )
+40 -35
View File
@@ -9,7 +9,6 @@
import datetime import datetime
import logging import logging
import re import re
import urllib.parse
from enum import Enum from enum import Enum
import psutil import psutil
@@ -22,6 +21,8 @@ from cellxgene_gateway.cellxgene_exception import CellxgeneException
from cellxgene_gateway.flask_util import querystring from cellxgene_gateway.flask_util import querystring
from cellxgene_gateway.util import current_time_stamp from cellxgene_gateway.util import current_time_stamp
logger = logging.getLogger(__name__)
class CacheEntryStatus(Enum): class CacheEntryStatus(Enum):
loaded = "loaded" loaded = "loaded"
@@ -57,7 +58,6 @@ class CacheEntry:
@classmethod @classmethod
def for_key(cls, key, port): def for_key(cls, key, port):
return cls( return cls(
None, None,
key, key,
@@ -111,7 +111,7 @@ class CacheEntry:
except psutil.NoSuchProcess: except psutil.NoSuchProcess:
pass pass
logging.getLogger("cellxgene_gateway").info(f"terminated {terminated}") logger.info(f"terminated {terminated}")
self.status = CacheEntryStatus.terminated self.status = CacheEntryStatus.terminated
def rewrite_text_content(self, cellxgene_content): def rewrite_text_content(self, cellxgene_content):
@@ -169,38 +169,43 @@ class CacheEntry:
full_path = self.cellxgene_basepath() + subpath + querystring() full_path = self.cellxgene_basepath() + subpath + querystring()
if request.method in ["GET", "HEAD", "OPTIONS"]: try:
cellxgene_response = get(full_path, headers=headers) cellxgene_response = None
elif request.method == "PUT": if request.method in ["GET", "HEAD", "OPTIONS"]:
cellxgene_response = put( cellxgene_response = get(full_path, headers=headers)
full_path, elif request.method == "PUT":
headers=headers, cellxgene_response = put(
data=request.data, full_path,
) headers=headers,
elif request.method == "POST": data=request.data,
cellxgene_response = post( )
full_path, elif request.method == "POST":
headers=headers, cellxgene_response = post(
data=request.data, full_path,
) headers=headers,
else: data=request.data,
raise CellxgeneException(f"Unexpected method {request.method}", 400) )
content_type = cellxgene_response.headers["content-type"] else:
if "text" in content_type: raise CellxgeneException(f"Unexpected method {request.method}", 400)
gateway_content = self.rewrite_text_content( content_type = cellxgene_response.headers["content-type"]
cellxgene_response.content.decode() if "text" in content_type:
) gateway_content = self.rewrite_text_content(
else: cellxgene_response.content.decode()
gateway_content = cellxgene_response.content )
else:
gateway_content = cellxgene_response.content
resp_headers = {} resp_headers = {}
for h in copy_headers: for h in copy_headers:
if h in cellxgene_response.headers: if h in cellxgene_response.headers:
resp_headers[h] = cellxgene_response.headers[h] resp_headers[h] = cellxgene_response.headers[h]
gateway_response = make_response( gateway_response = make_response(
gateway_content, gateway_content,
cellxgene_response.status_code, cellxgene_response.status_code,
resp_headers, resp_headers,
) )
finally:
if cellxgene_response is not None:
cellxgene_response.close()
return gateway_response return gateway_response
-2
View File
@@ -9,8 +9,6 @@
import os import os
from flask_api import status
from cellxgene_gateway import env from cellxgene_gateway import env
from cellxgene_gateway.cellxgene_exception import CellxgeneException from cellxgene_gateway.cellxgene_exception import CellxgeneException
+6 -3
View File
@@ -9,7 +9,6 @@
import logging import logging
import os import os
import socket
cellxgene_location = os.environ.get("CELLXGENE_LOCATION") cellxgene_location = os.environ.get("CELLXGENE_LOCATION")
cellxgene_data = os.environ.get("CELLXGENE_DATA", "") cellxgene_data = os.environ.get("CELLXGENE_DATA", "")
@@ -24,7 +23,9 @@ external_protocol = os.environ.get(
) )
ip = os.environ.get("GATEWAY_IP") ip = os.environ.get("GATEWAY_IP")
extra_scripts = os.environ.get("GATEWAY_EXTRA_SCRIPTS") extra_scripts = os.environ.get("GATEWAY_EXTRA_SCRIPTS")
ttl = os.environ.get("GATEWAY_TTL") 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 [ enable_annotations = os.environ.get("GATEWAY_ENABLE_ANNOTATIONS", "").lower() in [
"true", "true",
"1", "1",
@@ -33,6 +34,7 @@ enable_backed_mode = os.environ.get("GATEWAY_ENABLE_BACKED_MODE", "").lower() in
"true", "true",
"1", "1",
] ]
log_level = logging.getLevelName(os.environ.get("GATEWAY_LOG_LEVEL", "INFO"))
env_vars = { env_vars = {
"CELLXGENE_LOCATION": cellxgene_location, "CELLXGENE_LOCATION": cellxgene_location,
@@ -50,9 +52,10 @@ optional_env_vars = {
"GATEWAY_IP": ip, "GATEWAY_IP": ip,
"GATEWAY_PORT": gateway_port, "GATEWAY_PORT": gateway_port,
"GATEWAY_EXTRA_SCRIPTS": extra_scripts, "GATEWAY_EXTRA_SCRIPTS": extra_scripts,
"GATEWAY_TTL": ttl, "GATEWAY_EXPIRE_SECONDS": expire_seconds,
"GATEWAY_ENABLE_ANNOTATIONS": enable_annotations, "GATEWAY_ENABLE_ANNOTATIONS": enable_annotations,
"GATEWAY_ENABLE_BACKED_MODE": enable_backed_mode, "GATEWAY_ENABLE_BACKED_MODE": enable_backed_mode,
"GATEWAY_LOG_LEVEL": log_level,
"CELLXGENE_ARGS": cellxgene_args, "CELLXGENE_ARGS": cellxgene_args,
"CELLXGENE_DATA": cellxgene_data, "CELLXGENE_DATA": cellxgene_data,
"PROXY_FIX_FOR": proxy_fix_for, "PROXY_FIX_FOR": proxy_fix_for,
+14 -13
View File
@@ -7,31 +7,32 @@
# OR CONDITIONS OF ANY KIND, either express or implied. See the License for # OR CONDITIONS OF ANY KIND, either express or implied. See the License for
# the specific language governing permissions and limitations under the License. # the specific language governing permissions and limitations under the License.
import os import html
import urllib.parse import urllib.parse
from cellxgene_gateway import env, flask_util from cellxgene_gateway import flask_util
from cellxgene_gateway.cache_key import CacheKey from cellxgene_gateway.cache_key import CacheKey
from cellxgene_gateway.dir_util import annotations_suffix, make_annotations, make_h5ad from cellxgene_gateway.dir_util import annotations_suffix, make_annotations, make_h5ad
from cellxgene_gateway.env import enable_annotations
def render_annotations(item, item_source): def render_annotations(item, item_source):
if not enable_annotations:
return ""
url = flask_util.view_url( url = flask_util.view_url(
item_source.get_annotations_subpath(item), item_source.name item_source.get_annotations_subpath(item), item_source.name
) )
new_annotation = f"<a class='new' href='{url}'>new</a>" new_annotation = [f"<a class='new' href='{url}'>new</a>"]
annotations = ( annotations = (
", ".join( [
[ f"<a href='{CacheKey(item, item_source, a).view_url}/'>{html.escape(a.name)}</a>"
f"<a href='{CacheKey(item, item_source, a).view_url}/'>{a.name}</a>" for a in item.annotations
for a in item.annotations ]
]
)
+ ", "
if item.annotations if item.annotations
else "" else []
) )
return " | annotations: " + annotations + new_annotation return "| annotations: " + ", ".join(new_annotation + annotations)
def render_item(item, item_source): def render_item(item, item_source):
@@ -54,7 +55,7 @@ def render_item_tree(item_tree, item_source):
if item_tree.descriptor: if item_tree.descriptor:
descriptor = item_tree.descriptor.lstrip("/") descriptor = item_tree.descriptor.lstrip("/")
url = f"/filecrawl/{descriptor}?source={item_source.name}" url = f"/filecrawl/{descriptor}?source={item_source.name}"
name = descriptor.rsplit("/")[1] if descriptor.find("/") >= 0 else descriptor name = descriptor.rsplit("/", 1)[-1]
return f"<li><a href='{url}'>{name}</a>{html}</li>" return f"<li><a href='{url}'>{name}</a>{html}</li>"
else: else:
return html return html
+20 -10
View File
@@ -22,9 +22,7 @@ from flask import (
send_from_directory, send_from_directory,
url_for, url_for,
) )
from flask_api import status
from werkzeug.middleware.proxy_fix import ProxyFix from werkzeug.middleware.proxy_fix import ProxyFix
from werkzeug.utils import secure_filename
from cellxgene_gateway import env, flask_util from cellxgene_gateway import env, flask_util
from cellxgene_gateway.backend_cache import BackendCache from cellxgene_gateway.backend_cache import BackendCache
@@ -52,6 +50,14 @@ def _force_https(app):
return wrapper 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) app.wsgi_app = _force_https(app.wsgi_app)
if ( if (
env.proxy_fix_for > 0 env.proxy_fix_for > 0
@@ -74,7 +80,6 @@ cache = BackendCache()
@app.errorhandler(CellxgeneException) @app.errorhandler(CellxgeneException)
def handle_invalid_usage(error): def handle_invalid_usage(error):
message = f"{error.http_status} Error : {error.message}" message = f"{error.http_status} Error : {error.message}"
return ( return (
@@ -89,7 +94,6 @@ def handle_invalid_usage(error):
@app.errorhandler(ProcessException) @app.errorhandler(ProcessException)
def handle_invalid_process(error): def handle_invalid_process(error):
message = [] message = []
message.append(error.message) message.append(error.message)
@@ -157,10 +161,7 @@ def filecrawl(path=None):
path=path, path=path,
) )
) )
resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" set_no_cache(resp)
resp.headers["Pragma"] = "no-cache"
resp.headers["Expires"] = "0"
resp.headers["Cache-Control"] = "public, max-age=0"
return resp return resp
@@ -209,7 +210,10 @@ def do_view(path, source_name=None):
match.status == CacheEntryStatus.loaded match.status == CacheEntryStatus.loaded
or match.status == CacheEntryStatus.loading 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: elif match.status == CacheEntryStatus.error:
raise ProcessException.from_cache_entry(match) raise ProcessException.from_cache_entry(match)
@@ -267,6 +271,12 @@ def do_terminate(path):
return redirect(url_for("do_GET_status"), code=302) 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(): def launch():
env.validate() env.validate()
if not item_sources or not len(item_sources): if not item_sources or not len(item_sources):
@@ -287,7 +297,7 @@ def launch():
def main(): def main():
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=env.log_level,
format="%(asctime)s:%(name)s:%(levelname)s:%(message)s", format="%(asctime)s:%(name)s:%(levelname)s:%(message)s",
) )
cellxgene_data = os.environ.get("CELLXGENE_DATA", None) cellxgene_data = os.environ.get("CELLXGENE_DATA", None)
@@ -24,17 +24,22 @@ class FileItemSource(ItemSource):
h5ad_suffix=dir_util.h5ad_suffix, h5ad_suffix=dir_util.h5ad_suffix,
annotation_dir_suffix=dir_util.annotations_suffix, annotation_dir_suffix=dir_util.annotations_suffix,
annotation_file_suffix=".csv", annotation_file_suffix=".csv",
gene_set_file_suffix="_gene_sets.csv",
): ):
self._name = name self._name = name
self.base_path = base_path self.base_path = base_path
self.h5ad_suffix = h5ad_suffix self.h5ad_suffix = h5ad_suffix
self.annotation_dir_suffix = annotation_dir_suffix self.annotation_dir_suffix = annotation_dir_suffix
self.annotation_file_suffix = annotation_file_suffix self.annotation_file_suffix = annotation_file_suffix
self.gene_set_file_suffix = gene_set_file_suffix
@property @property
def name(self): def name(self):
return self._name or f"Files:{self.base_path}" return self._name or f"Files:{self.base_path}"
def is_gene_set(self, path: str) -> bool:
return path.endswith(self.gene_set_file_suffix)
def is_h5ad_file(self, path: str) -> bool: def is_h5ad_file(self, path: str) -> bool:
return path.endswith(self.h5ad_suffix) and os.path.isfile(path) return path.endswith(self.h5ad_suffix) and os.path.isfile(path)
@@ -63,7 +68,7 @@ class FileItemSource(ItemSource):
return item_tree return item_tree
def scan_directory(self, subpath="") -> dict: def scan_directory(self, subpath: str = "") -> ItemTree:
base_path = os.path.join(self.base_path, subpath) base_path = os.path.join(self.base_path, subpath)
if not os.path.exists(base_path): if not os.path.exists(base_path):
@@ -100,6 +105,11 @@ class FileItemSource(ItemSource):
branches = [ branches = [
self.scan_directory(os.path.join(subpath, subdir)) for subdir in subdirs self.scan_directory(os.path.join(subpath, subdir)) for subdir in subdirs
] ]
# Exclude branches without files as leaves. Since traversal is applied pre-order,
# branch.branches has already been processed and we don't need to check deeper nesting.
branches = [
branch for branch in branches if branch.items or branch.branches
]
return ItemTree(subpath, items, branches) return ItemTree(subpath, items, branches)
@@ -121,6 +131,9 @@ class FileItemSource(ItemSource):
if self.is_h5ad_file(full_path): if self.is_h5ad_file(full_path):
return self.shallowitem_from_descriptor(descriptor) return self.shallowitem_from_descriptor(descriptor)
def is_authorized(self, descriptor):
return True
def lookup(self, indescriptor: str) -> LookupResult: def lookup(self, indescriptor: str) -> LookupResult:
descriptor = indescriptor.strip("/") descriptor = indescriptor.strip("/")
if descriptor.endswith(self.annotation_file_suffix): if descriptor.endswith(self.annotation_file_suffix):
@@ -173,11 +186,29 @@ class FileItemSource(ItemSource):
annotations_subpath = self.get_annotations_subpath(item) annotations_subpath = self.get_annotations_subpath(item)
annotations_fullpath = self.full_path(annotations_subpath) annotations_fullpath = self.full_path(annotations_subpath)
if os.path.isdir(annotations_fullpath): if os.path.isdir(annotations_fullpath):
return [ sorted_files = sorted(os.listdir(annotations_fullpath))
annotation_files = [
self.make_fileitem_from_path(annotation, annotations_subpath, True) self.make_fileitem_from_path(annotation, annotations_subpath, True)
for annotation in sorted(os.listdir(annotations_fullpath)) for annotation in sorted_files
if annotation.endswith(self.annotation_file_suffix) if annotation.endswith(self.annotation_file_suffix)
and not self.is_gene_set(annotation)
and os.path.isfile(os.path.join(annotations_fullpath, annotation)) and os.path.isfile(os.path.join(annotations_fullpath, annotation))
] ]
# Catch gene sets without accompanying [annotations].csv
gene_sets_files = [
self.make_fileitem_from_path(
annotation[: -len(self.gene_set_file_suffix)] + ".csv",
annotations_subpath,
True,
)
for annotation in sorted_files
if self.is_gene_set(annotation)
and annotation[: -len(self.gene_set_file_suffix)]
not in [a.name for a in annotation_files]
and os.path.isfile(os.path.join(annotations_fullpath, annotation))
]
return sorted(annotation_files + gene_sets_files, key=lambda x: x.name)
else: else:
return None return None
+4
View File
@@ -40,6 +40,10 @@ class ItemSource(ABC):
def update(self, item: Item) -> None: def update(self, item: Item) -> None:
raise Exception('"update" unimplemented') raise Exception('"update" unimplemented')
@abstractmethod
def is_authorized(self, descriptor: str) -> bool:
raise Exception('"is_authorized" unimplemented')
@abstractmethod @abstractmethod
def lookup(self, descriptor: str) -> LookupResult: def lookup(self, descriptor: str) -> LookupResult:
raise Exception('"lookup" unimplemented') raise Exception('"lookup" unimplemented')
+28 -4
View File
@@ -7,9 +7,11 @@
# OR CONDITIONS OF ANY KIND, either express or implied. See the License for # OR CONDITIONS OF ANY KIND, either express or implied. See the License for
# the specific language governing permissions and limitations under the License. # the specific language governing permissions and limitations under the License.
from os.path import basename, dirname, join import os
from os.path import basename, dirname
from typing import List from typing import List
import flask
import s3fs import s3fs
from cellxgene_gateway import dir_util from cellxgene_gateway import dir_util
@@ -18,6 +20,10 @@ from cellxgene_gateway.items.item_source import ItemSource, LookupResult
from cellxgene_gateway.items.s3.s3item import S3Item from cellxgene_gateway.items.s3.s3item import S3Item
def truthy(val: str):
return val.lower() in ["true", "1"]
class S3ItemSource(ItemSource): class S3ItemSource(ItemSource):
def __init__( def __init__(
self, self,
@@ -28,7 +34,10 @@ class S3ItemSource(ItemSource):
annotation_file_suffix=".csv", annotation_file_suffix=".csv",
): ):
self._name = name 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://"): if bucket.startswith("s3://"):
raise Exception( raise Exception(
f"Bucket name should not include s3:// prefix, got {bucket}" f"Bucket name should not include s3:// prefix, got {bucket}"
@@ -67,6 +76,13 @@ class S3ItemSource(ItemSource):
item_tree = self.scan_directory("" if filter is None else filter) item_tree = self.scan_directory("" if filter is None else filter)
return item_tree 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: def scan_directory(self, directory_key="") -> dict:
url = self.url(directory_key) url = self.url(directory_key)
@@ -75,7 +91,7 @@ class S3ItemSource(ItemSource):
s3key_map = dict( s3key_map = dict(
(self.remove_bucket(filepath), "s3://" + filepath) (self.remove_bucket(filepath), "s3://" + filepath)
for filepath in sorted(self.s3.ls(url)) for filepath in sorted(self.s3.ls(url, refresh=self.refresh))
) )
def is_annotation_dir(dir_s3key): def is_annotation_dir(dir_s3key):
@@ -100,6 +116,9 @@ class S3ItemSource(ItemSource):
branches = None branches = None
if len(subdir_keys) > 0: if len(subdir_keys) > 0:
branches = [self.scan_directory(key) for key in subdir_keys] branches = [self.scan_directory(key) for key in subdir_keys]
branches = [
branch for branch in branches if branch.items or branch.branches
]
return ItemTree(directory_key, items, branches) return ItemTree(directory_key, items, branches)
@@ -113,6 +132,9 @@ class S3ItemSource(ItemSource):
def update(self, item: S3Item) -> None: def update(self, item: S3Item) -> None:
pass pass
def is_authorized(self, descriptor):
return True
def lookup_item(self, descriptor): def lookup_item(self, descriptor):
full_path = self.url(descriptor) full_path = self.url(descriptor)
if self.is_h5ad_url(full_path): if self.is_h5ad_url(full_path):
@@ -163,7 +185,9 @@ class S3ItemSource(ItemSource):
self.make_s3item_from_key( self.make_s3item_from_key(
basename(annotation), self.remove_bucket(annotation), True basename(annotation), self.remove_bucket(annotation), True
) )
for annotation in sorted(self.s3.ls(annotations_fullpath)) for annotation in sorted(
self.s3.ls(annotations_fullpath, refresh=self.refresh)
)
if annotation.endswith(self.annotation_file_suffix) if annotation.endswith(self.annotation_file_suffix)
and self.s3.isfile("s3://" + annotation) and self.s3.isfile("s3://" + annotation)
] ]
+1 -1
View File
@@ -18,7 +18,7 @@ logger = logging.getLogger(__name__)
class PruneProcessCache: class PruneProcessCache:
def __init__(self, cache): def __init__(self, cache):
self.cache = cache self.cache = cache
self.expire_seconds = 3600 if env.ttl is None else int(env.ttl) self.expire_seconds = env.expire_seconds
def __call__(self): def __call__(self):
while True: while True:
+12 -7
View File
@@ -9,14 +9,15 @@
import logging import logging
import subprocess import subprocess
from http import HTTPStatus
from flask_api import status
from cellxgene_gateway.cache_entry import CacheEntryStatus from cellxgene_gateway.cache_entry import CacheEntryStatus
from cellxgene_gateway.dir_util import make_annotations from cellxgene_gateway.dir_util import make_annotations
from cellxgene_gateway.env import cellxgene_args, enable_annotations, enable_backed_mode from cellxgene_gateway.env import cellxgene_args, enable_annotations, enable_backed_mode
from cellxgene_gateway.process_exception import ProcessException from cellxgene_gateway.process_exception import ProcessException
logger = logging.getLogger(__name__)
class SubprocessBackend: class SubprocessBackend:
def __init__(self): def __init__(self):
@@ -28,8 +29,11 @@ class SubprocessBackend:
extra_args = f" --annotations-dir {make_annotations(file_path)}" extra_args = f" --annotations-dir {make_annotations(file_path)}"
else: else:
extra_args = f" --annotations-file {annotation_file_path}" extra_args = f" --annotations-file {annotation_file_path}"
gene_sets_file_path = annotation_file_path[:-4] + "_gene_sets.csv"
extra_args += f" --gene-sets-file {gene_sets_file_path}"
else: else:
extra_args = " --disable-annotations" extra_args = " --disable-annotations"
extra_args += " --disable-gene-sets-save"
if enable_backed_mode: if enable_backed_mode:
extra_args += " --backed" extra_args += " --backed"
if not cellxgene_args is None: if not cellxgene_args is None:
@@ -55,7 +59,7 @@ class SubprocessBackend:
scripts, scripts,
cache_entry.key.annotation_file_path, cache_entry.key.annotation_file_path,
) )
logging.getLogger("cellxgene_gateway").info(f"launching {cmd}") logger.info(f"launching {cmd}")
process = subprocess.Popen( process = subprocess.Popen(
[cmd], stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True [cmd], stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True
) )
@@ -71,10 +75,10 @@ class SubprocessBackend:
or "Could not open file" in stderr or "Could not open file" in stderr
): ):
message = "File was invalid." message = "File was invalid."
http_status = status.HTTP_400_BAD_REQUEST http_status = HTTPStatus.BAD_REQUEST
else: else:
message = "Cellxgene failed to launch dataset." message = "Cellxgene failed to launch dataset."
http_status = status.HTTP_500_INTERNAL_SERVER_ERROR http_status = HTTPStatus.INTERNAL_SERVER_ERROR
cache_entry.status = CacheEntryStatus.error cache_entry.status = CacheEntryStatus.error
cache_entry.set_error(message, stderr, http_status) cache_entry.set_error(message, stderr, http_status)
@@ -84,5 +88,6 @@ class SubprocessBackend:
cache_entry.append_output(output) cache_entry.append_output(output)
cache_entry.set_loaded(process.pid) cache_entry.set_loaded(process.pid)
for output in process.communicate():
return logger.debug(f"cellxgene:{output}")
logger.info(f"exiting {cmd}")
@@ -75,7 +75,9 @@
const el = $(this); const el = $(this);
const ts = el.text(); const ts = el.text();
const dt = new Date(parseInt(ts * 1000)); const dt = new Date(parseInt(ts * 1000));
el.html(`${dt.toISOString()}<br>(${ts})`); el.prepend(`${dt.toISOString()}<br>(`);
el.append(')');
}); });
}) })
</script> </script>
+6 -3
View File
@@ -2,13 +2,16 @@ name: cellxgene-gateway
channels: channels:
- conda-forge - conda-forge
dependencies: dependencies:
- python=3.7 - python=3.11
- requests - requests
- flask - flask
- psutil - psutil
- black - black
- twine
- isort
- coverage - coverage
- pip - pip
- pip: - pip:
- flask-api - pre_commit
- cellxgene>=0.15 - werkzeug
- cellxgene
@@ -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"]
@@ -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.
@@ -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' {} \;
+2 -2
View File
@@ -1,5 +1,5 @@
cellxgene>=0.15 cellxgene
flask flask
flask_api werkzeug
psutil psutil
requests requests
+5 -2
View File
@@ -1,6 +1,7 @@
import unittest import unittest
from unittest.mock import MagicMock, Mock, patch from unittest.mock import MagicMock, Mock, patch
from cellxgene_gateway.gateway import app
from cellxgene_gateway.items.item import ItemType from cellxgene_gateway.items.item import ItemType
from cellxgene_gateway.items.s3.s3item import S3Item from cellxgene_gateway.items.s3.s3item import S3Item
from cellxgene_gateway.items.s3.s3item_source import S3ItemSource from cellxgene_gateway.items.s3.s3item_source import S3ItemSource
@@ -38,7 +39,8 @@ class TestScanDirectory(unittest.TestCase):
return True return True
raise Exception("exists called with " + path) raise Exception("exists called with " + path)
def ls(path): def ls(path, refresh):
assert refresh == True
if path == "s3://my-bucket/": if path == "s3://my-bucket/":
return [ return [
"my-bucket/lvl1", "my-bucket/lvl1",
@@ -80,7 +82,8 @@ class TestScanDirectory(unittest.TestCase):
s3func.return_value = S3Mock s3func.return_value = S3Mock
source = S3ItemSource("my-bucket") source = S3ItemSource("my-bucket")
tree = source.scan_directory() with app.test_request_context(query_string="refresh=true") as test_context:
tree = source.scan_directory()
def s3item_compare(i1, i2, msg=""): def s3item_compare(i1, i2, msg=""):
self.assertEqual(i1.name, i2.name, "name equals") self.assertEqual(i1.name, i2.name, "name equals")
+115 -10
View File
@@ -1,7 +1,12 @@
import unittest import unittest
from unittest.mock import MagicMock, patch from collections import defaultdict
from unittest.mock import patch
from cellxgene_gateway.filecrawl import render_item, render_item_source 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 import FileItem
from cellxgene_gateway.items.file.fileitem_source import FileItemSource from cellxgene_gateway.items.file.fileitem_source import FileItemSource
from cellxgene_gateway.items.item import ItemTree, ItemType from cellxgene_gateway.items.item import ItemTree, ItemType
@@ -9,26 +14,79 @@ from cellxgene_gateway.items.item import ItemTree, ItemType
source = FileItemSource("/tmp") source = FileItemSource("/tmp")
def make_entry(subpath="somepath", annotations=None):
return FileItem(
subpath=subpath,
name="entry",
ext=".h5ad",
type=ItemType.h5ad,
annotations=annotations,
)
class TestRenderEntry(unittest.TestCase): class TestRenderEntry(unittest.TestCase):
def test_GIVEN_path_both_slash_THEN_view_has_single_slash(self): def test_GIVEN_path_both_slash_THEN_view_has_single_slash(self):
entry = FileItem(subpath="/somepath/", name="entry", type=ItemType.h5ad) entry = make_entry(subpath="/somepath/")
rendered = render_item(entry, source) rendered = render_item(entry, source)
self.assertIn("view/somepath/entry/'", rendered) self.assertIn("view/somepath/entry.h5ad/'", rendered)
def test_GIVEN_path_starts_slash_THEN_view_has_single_slash(self): def test_GIVEN_path_starts_slash_THEN_view_has_single_slash(self):
entry = FileItem(subpath="/somepath", name="entry", type=ItemType.h5ad) entry = make_entry(subpath="/somepath")
rendered = render_item(entry, source) rendered = render_item(entry, source)
self.assertIn("view/somepath/entry/'", rendered) self.assertIn("view/somepath/entry.h5ad/'", rendered)
def test_GIVEN_path_ends_slash_THEN_view_has_single_slash(self): def test_GIVEN_path_ends_slash_THEN_view_has_single_slash(self):
entry = FileItem(subpath="somepath/", name="entry", type=ItemType.h5ad) entry = make_entry(subpath="somepath/")
rendered = render_item(entry, source) rendered = render_item(entry, source)
self.assertIn("view/somepath/entry/'", rendered) self.assertIn("view/somepath/entry.h5ad/'", rendered)
def test_GIVEN_path_no_slash_THEN_view_has_single_slash(self): def test_GIVEN_path_no_slash_THEN_view_has_single_slash(self):
entry = FileItem(subpath="somepath", name="entry", type=ItemType.h5ad) entry = make_entry(subpath="somepath")
rendered = render_item(entry, source) rendered = render_item(entry, source)
self.assertIn("view/somepath/entry/'", rendered) self.assertIn("view/somepath/entry.h5ad/'", rendered)
class TestRenderAnnotation(unittest.TestCase):
@patch("cellxgene_gateway.filecrawl.enable_annotations", new=True)
def test_GIVEN_no_annotation_THEN_new_alone(self):
entry = make_entry(annotations=None)
rendered = render_item(entry, source)
self.assertIn(
"> | annotations: <a class='new' href='/source/Files:/tmp/view/somepath/entry_annotations'>new</a></li>",
rendered,
)
@patch("cellxgene_gateway.filecrawl.enable_annotations", new=True)
def test_GIVEN_annotation_THEN_new_before(self):
annotation = FileItem(
subpath="somepath/entry_annotations",
name="annot",
ext=".csv",
type=ItemType.annotation,
)
entry = make_entry(annotations=[annotation])
rendered = render_item(entry, source)
self.assertIn(
"> | annotations: <a class='new' href='/source/Files:/tmp/view/somepath/entry_annotations'>new</a>,"
" <a href='/source/Files:/tmp/view/somepath/entry_annotations/annot.csv/'>annot</a></li>",
rendered,
)
@patch("cellxgene_gateway.filecrawl.enable_annotations", new=True)
def test_GIVEN_annotation_THEN_escaped(self):
annotation = FileItem(
subpath="somepath/entry_annotations",
name="hot&cold",
ext=".csv",
type=ItemType.annotation,
)
entry = make_entry(annotations=[annotation])
rendered = render_item(entry, source)
self.assertIn(
"> | annotations: <a class='new' href='/source/Files:/tmp/view/somepath/entry_annotations'>new</a>,"
" <a href='/source/Files:/tmp/view/somepath/entry_annotations/hot&cold.csv/'>hot&amp;cold</a></li>",
rendered,
)
class TestRenderItemSource(unittest.TestCase): class TestRenderItemSource(unittest.TestCase):
@@ -41,3 +99,50 @@ class TestRenderItemSource(unittest.TestCase):
rendered, 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>", "<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):
def setUp(self):
from cellxgene_gateway.gateway import app
self.app = app
self.app_context = self.app.test_request_context()
self.app_context.push()
@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_source.get_annotations_subpath = lambda _: "FakeAnnotations"
file_item = FileItem(
subpath="foo/bar/baz", name="file.h5ad", type=ItemType.h5ad
)
item_tree = ItemTree("foo/bar/baz", [file_item], [])
rendered = render_item_tree(item_tree, item_source)
self.assertEqual(
rendered,
"<li><a href='/filecrawl/foo/bar/baz?source=FakeSource'>baz</a><ul>"
"<li> <a href='/source/FakeSource/view/foo/bar/baz/file.h5ad/'>file.h5ad</a>"
" </li></ul></li>",
)
@patch(
"os.listdir",
side_effect=lambda parent: defaultdict(
list, {"tmp": ["foo"], "tmp/foo": ["bar"]}
)[parent],
)
@patch("os.path.exists", return_value=True)
def test_GIVEN_dirs_without_h5ad_THEN_excludes_dirs_in_output(
self, listdir, exists
):
# Directories:
# - tmp
# - foo
# - bar (no h5ad files)
item_source = FileItemSource("tmp", name="local")
item_tree = item_source.list_items("foo")
rendered = render_item_tree(item_tree, item_source)
self.assertEqual(
rendered,
"<li><a href='/filecrawl/foo?source=local'>foo</a><ul></ul></li>",
)
+1 -1
View File
@@ -15,7 +15,7 @@ key = CacheKey(
class TestPruneProcessCache(unittest.TestCase): class TestPruneProcessCache(unittest.TestCase):
@patch("cellxgene_gateway.util.current_time_stamp", new=lambda: 0) @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")
@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): def test_GIVEN_one_old_one_new_THEN_prune_old(self, old, new):
+37 -2
View File
@@ -1,7 +1,6 @@
import unittest import unittest
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from cellxgene_gateway.backend_cache import BackendCache
from cellxgene_gateway.cache_entry import CacheEntry from cellxgene_gateway.cache_entry import CacheEntry
from cellxgene_gateway.cache_key import CacheKey from cellxgene_gateway.cache_key import CacheKey
from cellxgene_gateway.items.file.fileitem import FileItem from cellxgene_gateway.items.file.fileitem import FileItem
@@ -33,10 +32,46 @@ class TestSubprocessBackend(unittest.TestCase):
backend.launch(cellxgene_loc, scripts, entry) backend.launch(cellxgene_loc, scripts, entry)
popen.assert_called_once_with( 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" "yes | /some/cellxgene launch /tmp/czi/pbmc3k.h5ad --port 8000 --host 127.0.0.1 --disable-annotations --disable-gene-sets-save --scripts http://example.com/script.js --scripts http://example.com/script2.js"
], ],
shell=True, shell=True,
stderr=-1, stderr=-1,
stdout=-1, stdout=-1,
) )
self.assertEqual("An unexpected error", context.exception.stderr) self.assertEqual("An unexpected error", context.exception.stderr)
@patch("subprocess.Popen")
def test_launch_GIVEN_annotations_enabled_THEN_set_flags(self, popen):
subprocess = MagicMock()
subprocess.stdout.readline().decode.return_value = (
"[cellxgene] Type CTRL-C at any time to exit.\n"
)
subprocess.stderr.read().decode.return_value = ""
popen.return_value = subprocess
key = CacheKey(
FileItem("/czi/", name="pbmc3k.h5ad", type=ItemType.h5ad),
FileItemSource("/tmp", "local"),
FileItem(
"/czi/pbmc3k_annotations/", name="foo.csv", type=ItemType.annotation
),
)
entry = CacheEntry.for_key(key, 8000)
import cellxgene_gateway.subprocess_backend
cellxgene_gateway.subprocess_backend.enable_annotations = True
try:
backend = cellxgene_gateway.subprocess_backend.SubprocessBackend()
cellxgene_loc = "/some/cellxgene"
backend.launch(cellxgene_loc, [], entry)
finally:
cellxgene_gateway.subprocess_backend.enable_annotations = False
popen.assert_called_once_with(
[
"yes | /some/cellxgene launch /tmp/czi/pbmc3k.h5ad --port 8000 --host 127.0.0.1 --annotations-file /tmp/czi/pbmc3k_annotations/foo.csv --gene-sets-file /tmp/czi/pbmc3k_annotations/foo_gene_sets.csv"
],
shell=True,
stderr=-1,
stdout=-1,
)