37 Commits

Author SHA1 Message Date
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
30 changed files with 529 additions and 123 deletions

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

View File

@@ -57,4 +57,11 @@ jobs:
- 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

View File

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

View File

@@ -1,6 +1,43 @@
# 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
* Added support for ProxyFix
# 0.2.2

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

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.

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.3"
__version__ = "0.3.7"

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"

View File

@@ -20,7 +20,7 @@ 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")

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

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

@@ -31,7 +31,6 @@ 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
@@ -46,12 +45,21 @@ 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
@@ -157,10 +165,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
@@ -216,7 +221,11 @@ def do_view(path, source_name=None):
@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"])
@@ -263,6 +272,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):

View File

View File

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:

View File

View File

@@ -38,8 +38,11 @@ class S3ItemSource(ItemSource):
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):
@@ -61,49 +64,44 @@ 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)
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) :].lstrip("/"), "s3://" + filepath)
(self.remove_bucket(filepath), "s3://" + filepath)
for filepath in sorted(self.s3.ls(url))
)
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(
@@ -163,11 +161,11 @@ 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))
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

@@ -39,9 +39,9 @@ class PruneProcessCache:
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

@@ -4,11 +4,15 @@ channels:
dependencies:
- python=3.7
- requests
- flask
- flask<2.0.0,>=1.0.2
- psutil
- black
- twine
- isort
- coverage
- pip
- pip:
- flask-api
- pre_commit
- flask-api==2.0
- werkzeug==1.0.1
- cellxgene>=0.15

View File

@@ -1,5 +1,6 @@
cellxgene>=0.15
flask
flask_api
flask<2.0.0,>=1.0.2
flask-api==2.0
werkzeug==1.0.1
psutil
requests

View File

@@ -1,10 +1,31 @@
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,
):

View File

View File

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

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,10 +1,10 @@
import unittest
from unittest.mock import MagicMock, patch
from cellxgene_gateway.filecrawl import render_item
from cellxgene_gateway.filecrawl import render_item, render_item_source
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.items.item import ItemTree, ItemType
source = FileItemSource("/tmp")
@@ -29,3 +29,15 @@ 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>",
)

View File

@@ -1,8 +1,16 @@
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):
@@ -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)