diff --git a/.github/workflows/pr-checks.yaml b/.github/workflows/pr-checks.yaml index 8a7c92b..f9b07f3 100644 --- a/.github/workflows/pr-checks.yaml +++ b/.github/workflows/pr-checks.yaml @@ -52,17 +52,39 @@ jobs: eval "$(conda shell.bash hook)" conda activate cellxgene-gateway coverage report --fail-under 41 + coverage report > coverage.txt + coverage html -i coverage xml -i - - name: "Upload coverage to Codecov" - if: ${{ github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) }} - uses: codecov/codecov-action@v1 + - name: Upload coverage HTML report + uses: actions/upload-artifact@v4 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 + name: coverage-html + path: htmlcov/ + retention-days: 30 + + - name: Upload coverage xml + uses: actions/upload-artifact@v4 + with: + name: coverage-xml + path: coverage.xml + retention-days: 30 + + - name: Upload coverage summary + uses: actions/upload-artifact@v4 + with: + name: coverage-summary + path: coverage.txt + retention-days: 30 + # - name: "Upload coverage to Codecov" + # if: ${{ github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) }} + # 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 diff --git a/README.md b/README.md index acb1528..48774ae 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,21 @@ docker run -it --rm \ -p 8080:8080 \ cellxgene-gateway ``` +## Running cellxgene gateway with start scripts + +For your convenience, we provide start scripts for flask, gunicorn and uwsgi. + +First, set up a .env +```bash +cp env_example .env +# edit .env +open .env +``` + +Then run the scripts in a subshell +```bash +( ./start_flask.sh ) +``` # Customization diff --git a/cellxgene_gateway/gateway.py b/cellxgene_gateway/gateway.py index f99d09e..1e6d00a 100644 --- a/cellxgene_gateway/gateway.py +++ b/cellxgene_gateway/gateway.py @@ -40,6 +40,12 @@ app = Flask(__name__) item_sources = [] default_item_source = None +# Guard for lazy initialization so tests can import this module without +# triggering environment-dependent side effects. initialize_data_sources() +# will set this to True when it has run. +data_sources_initialized = False +data_sources_init_lock = Lock() + def _force_https(app): def wrapper(environ, start_response): @@ -75,6 +81,46 @@ if ( x_prefix=env.proxy_fix_prefix, ) + +# WSGI middleware to ensure data sources are initialized before the first +# WSGI request is handled. This guarantees initialization works under +# Gunicorn/uWSGI (which import the module but don't call main()). The +# initialize_data_sources() function is idempotent-protected by +# data_sources_initialized and data_sources_init_lock. +def _init_on_first_wsgi_request(wsgi_app): + def middleware(environ, start_response): + global data_sources_initialized + if not data_sources_initialized: + with data_sources_init_lock: + if not app.extensions.get("cellxgene_gateway", {}).get("launchtime"): + app.extensions.setdefault("cellxgene_gateway", {})[ + "launchtime" + ] = current_time_stamp() + + if not data_sources_initialized: + initialize_data_sources() + + env.validate() + if not item_sources or not len(item_sources): + raise Exception( + "No data sources specified for Cellxgene Gateway" + ) + + global default_item_source + if default_item_source is None: + default_item_source = item_sources[0] + + data_sources_initialized = True + return wsgi_app(environ, start_response) + + return middleware + + +# Wrap the WSGI app so Gunicorn/uWSGI will trigger initialization when the +# first request comes in. Tests that need initialization can call +# initialize_data_sources() directly. +app.wsgi_app = _init_on_first_wsgi_request(app.wsgi_app) + cache = BackendCache() @@ -207,7 +253,7 @@ entry_lock = Lock() def matching_source(source_name): - if source_name is None: + if source_name is None and default_item_source is not None: source_name = default_item_source.name matching = [i for i in item_sources if i.name == source_name] if len(matching) != 1: @@ -254,6 +300,11 @@ def do_view(path, source_name=None): raise CellxgeneException("User not authorized to access this data", 403) elif match.status == CacheEntryStatus.error: raise ProcessException.from_cache_entry(match) + else: + raise CellxgeneException( + f"Unexpected cache entry status {match.status} for key {match.key.descriptor}", + 500, + ) @app.route("/cache_status", methods=["GET"]) @@ -267,28 +318,40 @@ def do_GET_status(): @app.route("/cache_status.json", methods=["GET"]) def do_GET_status_json(): + def map_entry(entry): + dataset = entry.key.h5ad_item.descriptor + annotation_file = entry.key.annotation_descriptor + return { + "dataset": dataset, + "annotation_file": annotation_file, + "launchtime": entry.launchtime, + "last_access": entry.timestamp, + "status": entry.status.name, + } + return json.dumps( { - "launchtime": app.launchtime, - "entry_list": [ - { - "dataset": entry.key.dataset, - "annotation_file": entry.key.annotation_file, - "launchtime": entry.launchtime, - "last_access": entry.timestamp, - "status": entry.status, - } - for entry in cache.entry_list - ], + "launchtime": app.extensions.get("cellxgene_gateway", {}).get("launchtime"), + "entry_list": [map_entry(entry) for entry in cache.entry_list], } ) -@app.route("/relaunch/", methods=["GET"]) -def do_relaunch(path): - source_name = request.args.get("source_name") or default_item_source.name +def get_cache_key(path): + if request.args.get("source_name"): + source_name = request.args.get("source_name") + elif default_item_source: + source_name = default_item_source.name + else: + source_name = None source = matching_source(source_name) key = CacheKey.for_lookup(source, source.lookup(path)) + return key + + +@app.route("/relaunch/", methods=["GET"]) +def do_relaunch(path): + key = get_cache_key(path) match = cache.check_entry(key) if not match is None: match.terminate() @@ -300,9 +363,7 @@ def do_relaunch(path): @app.route("/terminate/", methods=["GET"]) def do_terminate(path): - 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)) + key = get_cache_key(path) match = cache.check_entry(key) if not match is None: match.terminate() @@ -315,28 +376,25 @@ def ip_address(): return set_no_cache(resp) -def launch(): - env.validate() - if not item_sources or not len(item_sources): - raise Exception("No data sources specified for Cellxgene Gateway") - - global default_item_source - if default_item_source is None: - default_item_source = item_sources[0] - +def start_pruner_thread(): pruner = PruneProcessCache(cache) - - background_thread = Thread(target=pruner) + # Run the pruner as a daemon thread so it won't block interpreter + # shutdown (for example when Ctrl-C is used in the main thread). + # This avoids "Exception ignored in: " at exit. + background_thread = Thread(target=pruner, daemon=True) background_thread.start() - app.launchtime = current_time_stamp() + +def launch(): + start_pruner_thread() + + app.extensions.setdefault("cellxgene_gateway", {})[ + "launchtime" + ] = current_time_stamp() app.run(host="0.0.0.0", port=env.gateway_port, debug=False) -# When using servers like Gunicorn or uWSGI, this file is imported rather than run directly. -# As a result, the main() function is never called automatically. -# Therefore, we must initialize the data sources at import time to ensure they are available. -initialize_data_sources() +app.extensions.setdefault("cellxgene_gateway", {})["launchtime"] = None def main(): diff --git a/env_example b/env_example new file mode 100644 index 0000000..71a6b3b --- /dev/null +++ b/env_example @@ -0,0 +1,3 @@ +export CELLXGENE_LOCATION=$(pwd)/.venv/bin/cellxgene +export CELLXGENE_DATA=../cellxgene_data +export GATEWAY_IP=127.0.0.1 diff --git a/run.sh.example b/run.sh.example deleted file mode 100644 index cd0629c..0000000 --- a/run.sh.example +++ /dev/null @@ -1,6 +0,0 @@ -export CELLXGENE_LOCATION=$(pwd)/.cellxgene-gateway/bin/cellxgene -export CELLXGENE_DATA=../cellxgene_data -export GATEWAY_IP=127.0.0.1 - -#Once these are set, you run like a normal Flask app -cellxgene-gateway diff --git a/start_flask.sh b/start_flask.sh new file mode 100755 index 0000000..14387dd --- /dev/null +++ b/start_flask.sh @@ -0,0 +1,41 @@ +#!/bin/bash + +# start_gunicorn.sh - Start Cellxgene Gateway with Gunicorn +# +# PREREQUISITES: +# - Gunicorn installed (included with cellxgene 1.3.0, or: pip install gunicorn) +# - Virtual environment activated +# - .env file with CELLXGENE_LOCATION and CELLXGENE_DATA (or CELLXGENE_BUCKET) +# +# USAGE: +# ./start_gunicorn.sh + +# Exit on error +set -e + +# Get the directory where this script is located +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + + +# Source environment variables +echo "Loading environment variables..." +if [ -f "$SCRIPT_DIR/.env" ]; then + source "$SCRIPT_DIR/.env" +else + echo "Error: .env file not found at $SCRIPT_DIR/.env" + echo "Please create it with required environment variables" + exit 1 +fi + +# Verify required environment variables +if [ -z "$CELLXGENE_LOCATION" ]; then + echo "Error: CELLXGENE_LOCATION not set" + exit 1 +fi + +if [ -z "$CELLXGENE_DATA" ] && [ -z "$CELLXGENE_BUCKET" ]; then + echo "Error: Either CELLXGENE_DATA or CELLXGENE_BUCKET must be set" + exit 1 +fi + +exec cellxgene-gateway \ No newline at end of file diff --git a/start_gunicorn.sh b/start_gunicorn.sh new file mode 100755 index 0000000..eeef9bb --- /dev/null +++ b/start_gunicorn.sh @@ -0,0 +1,91 @@ +#!/bin/bash + +# start_gunicorn.sh - Start Cellxgene Gateway with Gunicorn +# +# PREREQUISITES: +# - Gunicorn installed (included with cellxgene 1.3.0, or: pip install gunicorn) +# - Virtual environment activated +# - .env file with CELLXGENE_LOCATION and CELLXGENE_DATA (or CELLXGENE_BUCKET) +# +# USAGE: +# ./start_gunicorn.sh + +# Exit on error +set -e + +# Get the directory where this script is located +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + +# Source environment variables +echo "Loading environment variables..." +if [ -f "$SCRIPT_DIR/.env" ]; then + source "$SCRIPT_DIR/.env" +else + echo "Error: .env file not found at $SCRIPT_DIR/.env" + echo "Please create it with required environment variables" + exit 1 +fi + +# Verify required environment variables +if [ -z "$CELLXGENE_LOCATION" ]; then + echo "Error: CELLXGENE_LOCATION not set" + exit 1 +fi + +if [ -z "$CELLXGENE_DATA" ] && [ -z "$CELLXGENE_BUCKET" ]; then + echo "Error: Either CELLXGENE_DATA or CELLXGENE_BUCKET must be set" + exit 1 +fi + +# Gunicorn configuration +# WARNING: Multi-worker mode has cache synchronization issues (see plans/002-shared-cache-implementation.md) +# Each worker maintains its own in-memory cache, causing 404s for static assets when different +# workers handle requests for the same dataset. Use GUNICORN_WORKERS=1 until shared cache is implemented. +WORKERS=${GUNICORN_WORKERS:-1} +BIND=${GATEWAY_IP:-0.0.0.0}:${GATEWAY_PORT:-5005} +TIMEOUT=${GUNICORN_TIMEOUT:-120} +WORKER_CLASS=${GUNICORN_WORKER_CLASS:-sync} +KEEPALIVE=${GUNICORN_KEEPALIVE:-5} +LOG_LEVEL=${GUNICORN_LOG_LEVEL:-info} + +# Production optimization: enable backed mode to reduce memory usage +export GATEWAY_ENABLE_BACKED_MODE=${GATEWAY_ENABLE_BACKED_MODE:-true} + +# Check if gunicorn is installed +if ! command -v gunicorn &> /dev/null; then + echo "Error: gunicorn not found. Install with: pip install gunicorn" + exit 1 +fi + +# Display configuration +echo "Starting Cellxgene Gateway with Gunicorn..." +echo "Configuration:" +echo " Data source: ${CELLXGENE_DATA:-$CELLXGENE_BUCKET}" +echo " Binding to: $BIND" +echo " Workers: $WORKERS" +echo " Worker class: $WORKER_CLASS" +echo " Timeout: ${TIMEOUT}s" +echo " Keepalive: ${KEEPALIVE}s" +echo " Log level: $LOG_LEVEL" +echo " Backed mode: ${GATEWAY_ENABLE_BACKED_MODE}" +echo "" + +cd "$SCRIPT_DIR" + +# Start Gunicorn with optimized settings +# Additional options you can add via environment variables: +# - GUNICORN_MAX_REQUESTS: Restart worker after N requests (prevents memory leaks) +# - GUNICORN_MAX_REQUESTS_JITTER: Add randomness to max-requests +exec gunicorn cellxgene_gateway.gateway:app \ + --workers "$WORKERS" \ + --worker-class "$WORKER_CLASS" \ + --bind "$BIND" \ + --timeout "$TIMEOUT" \ + --keep-alive "$KEEPALIVE" \ + --access-logfile - \ + --error-logfile - \ + --log-level "$LOG_LEVEL" \ + --preload \ + ${GUNICORN_MAX_REQUESTS:+--max-requests "$GUNICORN_MAX_REQUESTS"} \ + ${GUNICORN_MAX_REQUESTS_JITTER:+--max-requests-jitter "$GUNICORN_MAX_REQUESTS_JITTER"} \ + "$@" diff --git a/start_uwsgi.sh b/start_uwsgi.sh new file mode 100755 index 0000000..3482085 --- /dev/null +++ b/start_uwsgi.sh @@ -0,0 +1,89 @@ +#!/bin/bash + +# start_uwsgi.sh - Start Cellxgene Gateway with uWSGI +# +# PREREQUISITES: +# - uWSGI installed (pip install uwsgi) +# - Virtual environment activated +# - .env file with CELLXGENE_LOCATION and CELLXGENE_DATA (or CELLXGENE_BUCKET) +# +# USAGE: +# ./start_uwsgi.sh + +# Exit on error +set -e + +# Get the directory where this script is located +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + +# Source environment variables +echo "Loading environment variables..." +if [ -f "$SCRIPT_DIR/.env" ]; then + source "$SCRIPT_DIR/.env" +else + echo "Error: .env file not found at $SCRIPT_DIR/.env" + echo "Please create it with required environment variables" + exit 1 +fi + +# Verify required environment variables +if [ -z "$CELLXGENE_LOCATION" ]; then + echo "Error: CELLXGENE_LOCATION not set" + exit 1 +fi + +if [ -z "$CELLXGENE_DATA" ] && [ -z "$CELLXGENE_BUCKET" ]; then + echo "Error: Either CELLXGENE_DATA or CELLXGENE_BUCKET must be set" + exit 1 +fi + +# uWSGI configuration +# WARNING: Multi-worker mode has cache synchronization issues (see plans/002-shared-cache-implementation.md) +# Each worker maintains its own in-memory cache, causing 404s for static assets when different +# workers handle requests for the same dataset. Use UWSGI_WORKERS=1 until shared cache is implemented. +WORKERS=${UWSGI_WORKERS:-1} +HOST=${GATEWAY_IP:-0.0.0.0} +PORT=${GATEWAY_PORT:-5005} +TIMEOUT=${UWSGI_TIMEOUT:-120} +THREADS=${UWSGI_THREADS:-1} + +# Production optimization: enable backed mode to reduce memory usage +export GATEWAY_ENABLE_BACKED_MODE=${GATEWAY_ENABLE_BACKED_MODE:-true} + +# Check if uwsgi is installed +if ! command -v uwsgi &> /dev/null; then + echo "Error: uwsgi not found. Install with: pip install uwsgi" + exit 1 +else + # Display configuration + echo "Starting Cellxgene Gateway with uWSGI..." + echo "Configuration:" + echo " Data source: ${CELLXGENE_DATA:-$CELLXGENE_BUCKET}" + echo " Binding to: $HOST:$PORT" + echo " Workers: $WORKERS" + echo " Threads: $THREADS" + echo " Timeout: ${TIMEOUT}s" + echo " Backed mode: ${GATEWAY_ENABLE_BACKED_MODE}" + echo "" + + cd "$SCRIPT_DIR" + + # Start uWSGI with optimized settings + # Additional options you can add via environment variables: + # - UWSGI_MAX_REQUESTS: Restart worker after N requests (prevents memory leaks) + exec uwsgi \ + --http "$HOST:$PORT" \ + --module cellxgene_gateway.gateway:app \ + --workers "$WORKERS" \ + --threads "$THREADS" \ + --harakiri "$TIMEOUT" \ + --master \ + --enable-threads \ + --single-interpreter \ + --need-app \ + --die-on-term \ + --log-x-forwarded-for \ + ${UWSGI_MAX_REQUESTS:+--max-requests "$UWSGI_MAX_REQUESTS"} \ + "$@" +fi + diff --git a/tests/items/s3/test_s3item_source.py b/tests/items/s3/test_s3item_source.py index 1ff675d..99f615d 100644 --- a/tests/items/s3/test_s3item_source.py +++ b/tests/items/s3/test_s3item_source.py @@ -8,24 +8,16 @@ 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 +from cellxgene_gateway.gateway import app class TestScanDirectory(unittest.TestCase): def setUp(self): - self._tmpdir = tempfile.mkdtemp() - self._cellxgene_data = os.environ.get("CELLXGENE_DATA", "") - os.environ["CELLXGENE_DATA"] = self._tmpdir - - from cellxgene_gateway.gateway import app self.app = app def tearDown(self): - if self._cellxgene_data: - os.environ["CELLXGENE_DATA"] = self._cellxgene_data - else: - del os.environ["CELLXGENE_DATA"] - shutil.rmtree(self._tmpdir) + pass @patch("s3fs.S3FileSystem") def test_GIVEN_invalid_bucket_THEN_throws_error(self, s3func): diff --git a/tests/test_cache_entry.py b/tests/test_cache_entry.py index 3c1054f..30463e7 100644 --- a/tests/test_cache_entry.py +++ b/tests/test_cache_entry.py @@ -10,6 +10,7 @@ from cellxgene_gateway.cache_key import CacheKey from cellxgene_gateway.items.item import ItemType from cellxgene_gateway.items.file.fileitem import FileItem from cellxgene_gateway.items.file.fileitem_source import FileItemSource +from cellxgene_gateway.gateway import app key = CacheKey( FileItem("/czi/", name="pbmc3k.h5ad", type=ItemType.h5ad), @@ -19,12 +20,6 @@ key = CacheKey( class TestRenderEntry(unittest.TestCase): def setUp(self): - self._tmpdir = tempfile.mkdtemp() - self._cellxgene_data = os.environ.get("CELLXGENE_DATA", "") - os.environ["CELLXGENE_DATA"] = self._tmpdir - - from cellxgene_gateway.gateway import app - self.app = app self.app_context = self.app.test_request_context() self.app_context.push() @@ -32,11 +27,6 @@ class TestRenderEntry(unittest.TestCase): def tearDown(self): self.app_context.pop() - if self._cellxgene_data: - os.environ["CELLXGENE_DATA"] = self._cellxgene_data - else: - del os.environ["CELLXGENE_DATA"] - shutil.rmtree(self._tmpdir) def test_GIVEN_key_and_port_THEN_returns_loading_CacheEntry(self): entry = CacheEntry.for_key("some-key", 1) diff --git a/tests/test_filecrawl.py b/tests/test_filecrawl.py index d42d783..b77e9bd 100644 --- a/tests/test_filecrawl.py +++ b/tests/test_filecrawl.py @@ -13,6 +13,7 @@ from cellxgene_gateway.filecrawl import ( 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 +from cellxgene_gateway.gateway import app source = FileItemSource("/tmp") @@ -29,23 +30,12 @@ def make_entry(subpath="somepath", annotations=None): class TestRenderEntry(unittest.TestCase): def setUp(self): - self._tmpdir = tempfile.mkdtemp() - self._cellxgene_data = os.environ.get("CELLXGENE_DATA", "") - os.environ["CELLXGENE_DATA"] = self._tmpdir - - from cellxgene_gateway.gateway import app - self.app = app self.app_context = self.app.test_request_context() self.app_context.push() def tearDown(self): self.app_context.pop() - if self._cellxgene_data: - os.environ["CELLXGENE_DATA"] = self._cellxgene_data - else: - del os.environ["CELLXGENE_DATA"] - shutil.rmtree(self._tmpdir) def test_GIVEN_path_both_slash_THEN_view_has_single_slash(self): entry = make_entry(subpath="/somepath/") @@ -71,23 +61,12 @@ class TestRenderEntry(unittest.TestCase): class TestRenderAnnotation(unittest.TestCase): def setUp(self): - self._tmpdir = tempfile.mkdtemp() - self._cellxgene_data = os.environ.get("CELLXGENE_DATA", "") - os.environ["CELLXGENE_DATA"] = self._tmpdir - - from cellxgene_gateway.gateway import app - self.app = app self.app_context = self.app.test_request_context() self.app_context.push() def tearDown(self): self.app_context.pop() - if self._cellxgene_data: - os.environ["CELLXGENE_DATA"] = self._cellxgene_data - else: - del os.environ["CELLXGENE_DATA"] - shutil.rmtree(self._tmpdir) @patch("cellxgene_gateway.filecrawl.enable_annotations", new=True) def test_GIVEN_no_annotation_THEN_new_alone(self): @@ -145,23 +124,12 @@ class TestRenderItemSource(unittest.TestCase): class TestRenderItemTree(unittest.TestCase): def setUp(self): - self._tmpdir = tempfile.mkdtemp() - self._cellxgene_data = os.environ.get("CELLXGENE_DATA", "") - os.environ["CELLXGENE_DATA"] = self._tmpdir - - from cellxgene_gateway.gateway import app - self.app = app self.app_context = self.app.test_request_context() self.app_context.push() def tearDown(self): self.app_context.pop() - if self._cellxgene_data: - os.environ["CELLXGENE_DATA"] = self._cellxgene_data - else: - del os.environ["CELLXGENE_DATA"] - shutil.rmtree(self._tmpdir) @patch("cellxgene_gateway.items.file.fileitem_source.FileItemSource") def test_GIVEN_deep_nested_dirs_THEN_includes_dirs_in_output(self, item_source): diff --git a/tests/test_gateway_status_json.py b/tests/test_gateway_status_json.py new file mode 100644 index 0000000..fba4888 --- /dev/null +++ b/tests/test_gateway_status_json.py @@ -0,0 +1,54 @@ +import json +import unittest +from types import SimpleNamespace + +from cellxgene_gateway.gateway import do_GET_status_json, app, cache +from cellxgene_gateway.cache_entry import CacheEntry, CacheEntryStatus + + +class TestGatewayStatusJson(unittest.TestCase): + def test_do_GET_status_json_returns_expected_structure(self): + # Create a minimal fake key with required attributes + h5ad_item = SimpleNamespace(descriptor="somedir/dataset.h5ad") + key = SimpleNamespace( + h5ad_item=h5ad_item, + annotation_descriptor="somedir/dataset_annotations/foo.csv", + ) + + # Create a CacheEntry with known launchtime/timestamp/status + entry = CacheEntry( + None, + key, + 8000, + 111, + 222, + CacheEntryStatus.loaded, + None, + None, + None, + None, + ) + + # Install into the gateway cache and set app launchtime + cache.entry_list = [entry] + app.extensions.setdefault("cellxgene_gateway", {})["launchtime"] = "LAUNCH_TIME" + + rv = do_GET_status_json() + + data = json.loads(rv) + # top-level launchtime comes from app.extensions + self.assertEqual("LAUNCH_TIME", data["launchtime"]) + + self.assertIn("entry_list", data) + self.assertEqual(1, len(data["entry_list"])) + + e = data["entry_list"][0] + self.assertEqual("somedir/dataset.h5ad", e["dataset"]) + self.assertEqual("somedir/dataset_annotations/foo.csv", e["annotation_file"]) + self.assertEqual("loaded", e["status"]) + self.assertEqual(111, e["launchtime"]) + self.assertEqual(222, e["last_access"]) + + +if __name__ == "__main__": + unittest.main()