Compare commits

...
Author SHA1 Message Date
Alok Saldanha 0eed8bcb30 Moved prometheus-flask-exporter into extras_require 2022-09-05 15:04:35 -04:00
Bob Belnap 0f79e1a010 Moved prometheus monitoring into flask blueprint 2022-09-05 15:04:35 -04:00
Bob Belnap d5008893f7 add basic prometheus metrics with prometheus_flask_exporter 2022-08-01 09:45:47 -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
32 changed files with 565 additions and 362 deletions
+1
View File
@@ -39,6 +39,7 @@ jobs:
conda env create -f environment.yml
eval "$(conda shell.bash hook)"
conda activate cellxgene-gateway
pip install markupsafe==2.0.1 # temporary workaround for jinja2-2.11.3 calling soft_unicode in markupsafe
python setup.py install
- name: Run tests
-1
View File
@@ -8,7 +8,6 @@ repos:
types: [python]
stages: [commit]
- id: black
language_version: python3.6+
name: black
language: system
entry: black
+31
View File
@@ -1,3 +1,34 @@
# 0.3.10
* #65 Added GATEWAY_EXPIRE_SECONDS to set how long cellxgene servers can remain idle before being terminated.
* Added GATEWAY_LOG_LEVEL to set the log level
* #68 Close connections after reading response
* #68 Background thread reads from output of cellxgene process until it exits
# 0.3.9
* Added S3_ENABLE_LISTINGS_CACHE variable (See README.md)
# 0.3.8
* Fixed bug #57 affecting deeply nested subdirectory listing
# 0.3.7
* added back /metadata/ip_address endpoint
# 0.3.6
* pinned version of werkzeug
# 0.3.5
* Pinned flask version to match cellxgene 0.17.0
# 0.3.4
* Fixed bug #50 affecting subdirectory listing
# 0.3.3
* Fixed bug #48 affecting cache pruning
+8
View File
@@ -0,0 +1,8 @@
FROM python:3.9
RUN pip install cellxgene-gateway 'MarkupSafe<2.1'
ENV CELLXGENE_DATA=/cellxgene-data
ENV CELLXGENE_LOCATION=/usr/local/bin/cellxgene
CMD ["cellxgene-gateway"]
+45
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"
* `GATEWAY_IP` - ip addess of instance gateway is running on, mostly used to display SSH instructions. Defaults to `socket.gethostbyname(socket.gethostname())`
* `GATEWAY_PORT` - local port that the gateway should bind to, defaults to 5005
* `GATEWAY_EXPIRE_SECONDS` - time in seconds that a cellxgene process will remain idle before being terminated. Defaults to 3600 (one hour)
* `GATEWAY_EXTRA_SCRIPTS` - JSON array of script paths, will be embedded into each page and forwarded with `--scripts` to cellxgene server
* `GATEWAY_ENABLE_ANNOTATIONS` - Set to `true` or to `1` to enable cellxgene annotations.
* `GATEWAY_ENABLE_BACKED_MODE` - Set to `true` or to `1` to load AnnData in file-backed mode. This saves memory and speeds up launch time but may reduce overall performance.
* `GATEWAY_LOG_LEVEL` - default is `INFO`. set to `DEBUG` to increase logging and to `WARNING` to decrease logging.
* `S3_ENABLE_LISTINGS_CACHE` - Set to `true` or to `1` to cache listings of S3 folders for performance. If the cache becomes stale, set `filecrawl.html?refresh=true` query parameter to refresh the cache.
If any of the following optional variables are set, [ProxyFix](https://werkzeug.palletsprojects.com/en/1.0.x/middleware/proxy_fix/) will be used.
* `PROXY_FIX_FOR` - Number of upstream proxies setting X-Forwarded-For
@@ -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.
## Running cellxgene-gateway with Docker
First, build Docker image:
```bash
docker build -t cellxgene-gateway .
```
Then, cellxgene-gateway can be launched as such:
```bash
docker run -it --rm \
-v <local_data_dir>:/cellxgene-data \
-p 5005:5005 \
cellxgene-gateway
```
Additional environment variables can be provided with the `-e` parameter:
```bash
docker run -it --rm \
-v <local_data_dir>:/cellxgene-data \
-e GATEWAY_PORT=8080 \
-p 8080:8080 \
cellxgene-gateway
```
# Customization
The current paradigm for customization is to modify files during a build or deployment phase:
@@ -156,6 +186,21 @@ pip install isort flake8 black
isort -rc . # rc means recursive, and was deprecated in dev version of isort
black .
```
## Dependency management
We use the following files for dependency management:
* environment.yml - specifies a conda environment sufficient to run the packages
* setup.cfg - lists static dependency information, both minimal and extra dependencies
* setup.py - dynamically generated package information
* requirements.txt - simple wrapper invoking setup.py
For more details, see the folloiwng links:
* https://towardsdatascience.com/setuptools-python-571e7d5500f2
* https://towardsdatascience.com/requirements-vs-setuptools-python-ae3ee66e28af
# Getting Help
+1 -1
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.3.3"
__version__ = "0.3.10"
+40 -34
View File
@@ -9,7 +9,6 @@
import datetime
import logging
import re
import urllib.parse
from enum import Enum
import psutil
@@ -22,6 +21,8 @@ from cellxgene_gateway.cellxgene_exception import CellxgeneException
from cellxgene_gateway.flask_util import querystring
from cellxgene_gateway.util import current_time_stamp
logger = logging.getLogger(__name__)
class CacheEntryStatus(Enum):
loaded = "loaded"
@@ -111,7 +112,7 @@ class CacheEntry:
except psutil.NoSuchProcess:
pass
logging.getLogger("cellxgene_gateway").info(f"terminated {terminated}")
logger.info(f"terminated {terminated}")
self.status = CacheEntryStatus.terminated
def rewrite_text_content(self, cellxgene_content):
@@ -169,38 +170,43 @@ class CacheEntry:
full_path = self.cellxgene_basepath() + subpath + querystring()
if request.method in ["GET", "HEAD", "OPTIONS"]:
cellxgene_response = get(full_path, headers=headers)
elif request.method == "PUT":
cellxgene_response = put(
full_path,
headers=headers,
data=request.data,
)
elif request.method == "POST":
cellxgene_response = post(
full_path,
headers=headers,
data=request.data,
)
else:
raise CellxgeneException(f"Unexpected method {request.method}", 400)
content_type = cellxgene_response.headers["content-type"]
if "text" in content_type:
gateway_content = self.rewrite_text_content(
cellxgene_response.content.decode()
)
else:
gateway_content = cellxgene_response.content
try:
cellxgene_response = None
if request.method in ["GET", "HEAD", "OPTIONS"]:
cellxgene_response = get(full_path, headers=headers)
elif request.method == "PUT":
cellxgene_response = put(
full_path,
headers=headers,
data=request.data,
)
elif request.method == "POST":
cellxgene_response = post(
full_path,
headers=headers,
data=request.data,
)
else:
raise CellxgeneException(f"Unexpected method {request.method}", 400)
content_type = cellxgene_response.headers["content-type"]
if "text" in content_type:
gateway_content = self.rewrite_text_content(
cellxgene_response.content.decode()
)
else:
gateway_content = cellxgene_response.content
resp_headers = {}
for h in copy_headers:
if h in cellxgene_response.headers:
resp_headers[h] = cellxgene_response.headers[h]
resp_headers = {}
for h in copy_headers:
if h in cellxgene_response.headers:
resp_headers[h] = cellxgene_response.headers[h]
gateway_response = make_response(
gateway_content,
cellxgene_response.status_code,
resp_headers,
)
gateway_response = make_response(
gateway_content,
cellxgene_response.status_code,
resp_headers,
)
finally:
if cellxgene_response is not None:
cellxgene_response.close()
return gateway_response
+6 -3
View File
@@ -9,7 +9,6 @@
import logging
import os
import socket
cellxgene_location = os.environ.get("CELLXGENE_LOCATION")
cellxgene_data = os.environ.get("CELLXGENE_DATA", "")
@@ -24,7 +23,9 @@ external_protocol = os.environ.get(
)
ip = os.environ.get("GATEWAY_IP")
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 [
"true",
"1",
@@ -33,6 +34,7 @@ enable_backed_mode = os.environ.get("GATEWAY_ENABLE_BACKED_MODE", "").lower() in
"true",
"1",
]
log_level = logging.getLevelName(os.environ.get("GATEWAY_LOG_LEVEL", "INFO"))
env_vars = {
"CELLXGENE_LOCATION": cellxgene_location,
@@ -50,9 +52,10 @@ optional_env_vars = {
"GATEWAY_IP": ip,
"GATEWAY_PORT": gateway_port,
"GATEWAY_EXTRA_SCRIPTS": extra_scripts,
"GATEWAY_TTL": ttl,
"GATEWAY_EXPIRE_SECONDS": expire_seconds,
"GATEWAY_ENABLE_ANNOTATIONS": enable_annotations,
"GATEWAY_ENABLE_BACKED_MODE": enable_backed_mode,
"GATEWAY_LOG_LEVEL": log_level,
"CELLXGENE_ARGS": cellxgene_args,
"CELLXGENE_DATA": cellxgene_data,
"PROXY_FIX_FOR": proxy_fix_for,
+1 -1
View File
@@ -54,7 +54,7 @@ def render_item_tree(item_tree, item_source):
if item_tree.descriptor:
descriptor = item_tree.descriptor.lstrip("/")
url = f"/filecrawl/{descriptor}?source={item_source.name}"
name = descriptor.rsplit("/")[1] if descriptor.find("/") >= 0 else descriptor
name = descriptor.rsplit("/", 1)[-1]
return f"<li><a href='{url}'>{name}</a>{html}</li>"
else:
return html
+2 -2
View File
@@ -26,8 +26,8 @@ def url(endpoint, descriptor, source_name):
def view_url(descriptor, source_name):
return url("do_view", descriptor, source_name)
return url("gateway_blueprint.do_view", descriptor, source_name)
def relaunch_url(descriptor, source_name):
return url("do_relaunch", descriptor, source_name)
return url("gateway_blueprint.do_relaunch", descriptor, source_name)
+25 -258
View File
@@ -1,47 +1,15 @@
# 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 BaseHTTPServer
import json
import logging
import os
import urllib.parse
from threading import Lock, Thread
from flask import (
Flask,
make_response,
redirect,
render_template,
request,
send_from_directory,
url_for,
)
from flask_api import status
import typer
from flask import Flask
from werkzeug.middleware.proxy_fix import ProxyFix
from werkzeug.utils import secure_filename
from cellxgene_gateway import env, flask_util
from cellxgene_gateway.backend_cache import BackendCache
from cellxgene_gateway.cache_entry import CacheEntryStatus
from cellxgene_gateway.cache_key import CacheKey
from cellxgene_gateway.cellxgene_exception import CellxgeneException
from cellxgene_gateway.extra_scripts import get_extra_scripts
from cellxgene_gateway.filecrawl import render_item_source
from cellxgene_gateway.process_exception import ProcessException
from cellxgene_gateway.prune_process_cache import PruneProcessCache
from cellxgene_gateway import env, flask_util, gateway_blueprint
from cellxgene_gateway.util import current_time_stamp
app = Flask(__name__)
item_sources = []
default_item_source = None
def _force_https(app):
def wrapper(environ, start_response):
@@ -69,225 +37,10 @@ if (
x_prefix=env.proxy_fix_prefix,
)
cache = BackendCache()
@app.errorhandler(CellxgeneException)
def handle_invalid_usage(error):
message = f"{error.http_status} Error : {error.message}"
return (
render_template(
"cellxgene_error.html",
extra_scripts=get_extra_scripts(),
message=message,
),
error.http_status,
)
@app.errorhandler(ProcessException)
def handle_invalid_process(error):
message = []
message.append(error.message)
message.append(f"{error.http_status} Error.")
message.append(f"Stdout: {error.stdout}")
message.append(f"Stderr: {error.stderr}")
return (
render_template(
"process_error.html",
extra_scripts=get_extra_scripts(),
message=error.message,
http_status=error.http_status,
stdout=error.stdout,
stderr=error.stderr,
relaunch_url=error.key.relaunch_url(),
annotation_file=error.key.annotation_descriptor,
),
error.http_status,
)
@app.route("/favicon.ico")
def favicon():
return send_from_directory(
os.path.join(app.root_path, "static"),
"nibr.ico",
mimetype="image/vnd.microsof.icon",
)
@app.route("/")
def index():
return render_template(
"index.html",
ip=env.ip,
cellxgene_data=env.cellxgene_data,
extra_scripts=get_extra_scripts(),
)
@app.route("/filecrawl.html")
@app.route("/filecrawl/<path:path>")
def filecrawl(path=None):
source_name = request.args.get("source")
sources = (
filter(
lambda x: x.name == urllib.parse.unquote_plus(source_name),
item_sources,
)
if source_name
else item_sources
)
# loop all data sources --
rendered_sources = [
render_item_source(item_source, path) for item_source in sources
] # will we need to make this async in the page???
rendered_html = "\n".join(rendered_sources)
resp = make_response(
render_template(
"filecrawl.html",
extra_scripts=get_extra_scripts(),
rendered_html=rendered_html,
path=path,
)
)
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
entry_lock = Lock()
def matching_source(source_name):
if source_name is None:
source_name = default_item_source.name
matching = [i for i in item_sources if i.name == source_name]
if len(matching) != 1:
raise Exception(f"Could not find matching item source {source_name}")
source = matching[0]
return source
@app.route(
"/source/<path:source_name>/view/<path:path>",
methods=["GET", "PUT", "POST"],
)
@app.route("/view/<path:path>", methods=["GET", "PUT", "POST"])
def do_view(path, source_name=None):
source = matching_source(source_name)
match = cache.check_path(source, path)
if match is None:
lookup = source.lookup(path)
if lookup is None:
raise CellxgeneException(
f"Could not find item for path {path} in source {source.name}",
404,
)
key = CacheKey.for_lookup(source, lookup)
print(
f"view path={path}, source_name={source_name}, dataset={key.file_path}, annotation_file= {key.annotation_file_path}, key={key.descriptor}, source={key.source_name}"
)
with entry_lock:
match = cache.check_entry(key)
if match is None:
uascripts = get_extra_scripts()
match = cache.create_entry(key, uascripts)
match.timestamp = current_time_stamp()
if (
match.status == CacheEntryStatus.loaded
or match.status == CacheEntryStatus.loading
):
return match.serve_content(path)
elif match.status == CacheEntryStatus.error:
raise ProcessException.from_cache_entry(match)
@app.route("/cache_status", methods=["GET"])
def do_GET_status():
return render_template(
"cache_status.html",
entry_list=cache.entry_list,
extra_scripts=get_extra_scripts(),
)
@app.route("/cache_status.json", methods=["GET"])
def do_GET_status_json():
return json.dumps(
{
"launchtime": app.launchtime,
"entry_list": [
{
"dataset": entry.key.dataset,
"annotation_file": entry.key.annotation_file,
"launchtime": entry.launchtime,
"last_access": entry.timestamp,
"status": entry.status,
}
for entry in cache.entry_list
],
}
)
@app.route("/relaunch/<path:path>", methods=["GET"])
def do_relaunch(path):
source_name = request.args.get("source_name") or default_item_source.name
source = matching_source(source_name)
key = CacheKey.for_lookup(source, source.lookup(path))
match = cache.check_entry(key)
if not match is None:
match.terminate()
return redirect(
key.view_url,
code=302,
)
@app.route("/terminate/<path:path>", 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))
match = cache.check_entry(key)
if not match is None:
match.terminate()
return redirect(url_for("do_GET_status"), code=302)
def launch():
env.validate()
if not item_sources or not len(item_sources):
raise Exception("No data sources specified for Cellxgene Gateway")
global default_item_source
if default_item_source is None:
default_item_source = item_sources[0]
pruner = PruneProcessCache(cache)
background_thread = Thread(target=pruner)
background_thread.start()
app.launchtime = current_time_stamp()
app.run(host="0.0.0.0", port=env.gateway_port, debug=False)
def main():
def main(prometheus: bool = False):
logging.basicConfig(
level=logging.INFO,
level=env.log_level,
format="%(asctime)s:%(name)s:%(levelname)s:%(message)s",
)
cellxgene_data = os.environ.get("CELLXGENE_DATA", None)
@@ -296,19 +49,33 @@ def main():
if cellxgene_bucket is not None:
from cellxgene_gateway.items.s3.s3item_source import S3ItemSource
item_sources.append(S3ItemSource(cellxgene_bucket, name="s3"))
gateway_blueprint.item_sources.append(S3ItemSource(cellxgene_bucket, name="s3"))
default_item_source = "s3"
if cellxgene_data is not None:
from cellxgene_gateway.items.file.fileitem_source import FileItemSource
item_sources.append(FileItemSource(cellxgene_data, name="local"))
gateway_blueprint.item_sources.append(
FileItemSource(cellxgene_data, name="local")
)
default_item_source = "local"
if len(item_sources) == 0:
if len(gateway_blueprint.item_sources) == 0:
raise Exception("Please specify CELLXGENE_DATA or CELLXGENE_BUCKET")
flask_util.include_source_in_url = len(item_sources) > 1
flask_util.include_source_in_url = len(gateway_blueprint.item_sources) > 1
launch()
if prometheus:
from cellxgene_gateway.prometheus import add_metrics
add_metrics(app)
app.register_blueprint(gateway_blueprint.gateway_blueprint)
gateway_blueprint.launch()
app.launchtime = current_time_stamp()
app.run(host="0.0.0.0", port=env.gateway_port, debug=False)
def run():
typer.run(main)
if __name__ == "__main__":
main()
run()
+266
View File
@@ -0,0 +1,266 @@
# 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 BaseHTTPServer
import json
import os
import urllib.parse
from threading import Lock, Thread
from flask import (
Blueprint,
current_app,
make_response,
redirect,
render_template,
request,
send_from_directory,
url_for,
)
from cellxgene_gateway import env
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.extra_scripts import get_extra_scripts
from cellxgene_gateway.filecrawl import render_item_source
from cellxgene_gateway.process_exception import ProcessException
from cellxgene_gateway.prune_process_cache import PruneProcessCache
from cellxgene_gateway.util import current_time_stamp
gateway_blueprint = Blueprint("gateway_blueprint", __name__)
item_sources = []
default_item_source = None
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
cache = BackendCache()
@gateway_blueprint.errorhandler(CellxgeneException)
def handle_invalid_usage(error):
message = f"{error.http_status} Error : {error.message}"
return (
render_template(
"cellxgene_error.html",
extra_scripts=get_extra_scripts(),
message=message,
),
error.http_status,
)
@gateway_blueprint.errorhandler(ProcessException)
def handle_invalid_process(error):
message = []
message.append(error.message)
message.append(f"{error.http_status} Error.")
message.append(f"Stdout: {error.stdout}")
message.append(f"Stderr: {error.stderr}")
return (
render_template(
"process_error.html",
extra_scripts=get_extra_scripts(),
message=error.message,
http_status=error.http_status,
stdout=error.stdout,
stderr=error.stderr,
relaunch_url=error.key.relaunch_url(),
annotation_file=error.key.annotation_descriptor,
),
error.http_status,
)
@gateway_blueprint.route("/favicon.ico")
def favicon():
return send_from_directory(
os.path.join(current_app.root_path, "static"),
"nibr.ico",
mimetype="image/vnd.microsof.icon",
)
@gateway_blueprint.route("/")
def index():
return render_template(
"index.html",
ip=env.ip,
cellxgene_data=env.cellxgene_data,
extra_scripts=get_extra_scripts(),
)
@gateway_blueprint.route("/filecrawl.html")
@gateway_blueprint.route("/filecrawl/<path:path>")
def filecrawl(path=None):
source_name = request.args.get("source")
sources = (
filter(
lambda x: x.name == urllib.parse.unquote_plus(source_name),
item_sources,
)
if source_name
else item_sources
)
# loop all data sources --
rendered_sources = [
render_item_source(item_source, path) for item_source in sources
] # will we need to make this async in the page???
rendered_html = "\n".join(rendered_sources)
resp = make_response(
render_template(
"filecrawl.html",
extra_scripts=get_extra_scripts(),
rendered_html=rendered_html,
path=path,
)
)
set_no_cache(resp)
return resp
entry_lock = Lock()
def matching_source(source_name):
if source_name is None:
source_name = default_item_source.name
matching = [i for i in item_sources if i.name == source_name]
if len(matching) != 1:
raise Exception(f"Could not find matching item source {source_name}")
source = matching[0]
return source
@gateway_blueprint.route(
"/source/<path:source_name>/view/<path:path>",
methods=["GET", "PUT", "POST"],
)
@gateway_blueprint.route("/view/<path:path>", methods=["GET", "PUT", "POST"])
def do_view(path, source_name=None):
source = matching_source(source_name)
match = cache.check_path(source, path)
if match is None:
lookup = source.lookup(path)
if lookup is None:
raise CellxgeneException(
f"Could not find item for path {path} in source {source.name}",
404,
)
key = CacheKey.for_lookup(source, lookup)
print(
f"view path={path}, source_name={source_name}, dataset={key.file_path}, annotation_file= {key.annotation_file_path}, key={key.descriptor}, source={key.source_name}"
)
with entry_lock:
match = cache.check_entry(key)
if match is None:
uascripts = get_extra_scripts()
match = cache.create_entry(key, uascripts)
match.timestamp = current_time_stamp()
if (
match.status == CacheEntryStatus.loaded
or match.status == CacheEntryStatus.loading
):
if source.is_authorized(match.key.descriptor):
return match.serve_content(path)
else:
raise CellxgeneException("User not authorized to access this data", 403)
elif match.status == CacheEntryStatus.error:
raise ProcessException.from_cache_entry(match)
@gateway_blueprint.route("/cache_status", methods=["GET"])
def do_GET_status():
return render_template(
"cache_status.html",
entry_list=cache.entry_list,
extra_scripts=get_extra_scripts(),
)
@gateway_blueprint.route("/cache_status.json", methods=["GET"])
def do_GET_status_json():
return json.dumps(
{
"launchtime": current_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
],
}
)
@gateway_blueprint.route("/relaunch/<path:path>", methods=["GET"])
def do_relaunch(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))
match = cache.check_entry(key)
if not match is None:
match.terminate()
return redirect(
key.view_url,
code=302,
)
@gateway_blueprint.route("/terminate/<path:path>", 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))
match = cache.check_entry(key)
if not match is None:
match.terminate()
return redirect(url_for("gateway_blueprint.do_GET_status"), code=302)
@gateway_blueprint.route("/metadata/ip_address", methods=["GET"])
def ip_address():
resp = make_response(env.ip)
return set_no_cache(resp)
def launch():
env.validate()
if not item_sources or not len(item_sources):
raise Exception("No data sources specified for Cellxgene Gateway")
global default_item_source
if default_item_source is None:
default_item_source = item_sources[0]
pruner = PruneProcessCache(cache)
background_thread = Thread(target=pruner)
background_thread.start()
@@ -121,6 +121,9 @@ class FileItemSource(ItemSource):
if self.is_h5ad_file(full_path):
return self.shallowitem_from_descriptor(descriptor)
def is_authorized(self, descriptor):
return True
def lookup(self, indescriptor: str) -> LookupResult:
descriptor = indescriptor.strip("/")
if descriptor.endswith(self.annotation_file_suffix):
+4
View File
@@ -40,6 +40,10 @@ class ItemSource(ABC):
def update(self, item: Item) -> None:
raise Exception('"update" unimplemented')
@abstractmethod
def is_authorized(self, descriptor: str) -> bool:
raise Exception('"is_authorized" unimplemented')
@abstractmethod
def lookup(self, descriptor: str) -> LookupResult:
raise Exception('"lookup" unimplemented')
+25 -4
View File
@@ -7,9 +7,11 @@
# OR CONDITIONS OF ANY KIND, either express or implied. See the License for
# 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
import flask
import s3fs
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
def truthy(val: str):
return val.lower() in ["true", "1"]
class S3ItemSource(ItemSource):
def __init__(
self,
@@ -28,7 +34,10 @@ class S3ItemSource(ItemSource):
annotation_file_suffix=".csv",
):
self._name = name
self.s3 = s3fs.S3FileSystem()
enable_cache = os.environ.get("S3_ENABLE_LISTINGS_CACHE", "false").lower()
assert enable_cache in ["0", "1", "false", "true"]
self.use_listings_cache = truthy(enable_cache)
self.s3 = s3fs.S3FileSystem(use_listings_cache=self.use_listings_cache)
if bucket.startswith("s3://"):
raise Exception(
f"Bucket name should not include s3:// prefix, got {bucket}"
@@ -67,6 +76,13 @@ class S3ItemSource(ItemSource):
item_tree = self.scan_directory("" if filter is None else filter)
return item_tree
@property
def refresh(self):
return (
truthy(flask.request.args.get("refresh", default="false"))
or not self.use_listings_cache
)
def scan_directory(self, directory_key="") -> dict:
url = self.url(directory_key)
@@ -75,7 +91,7 @@ class S3ItemSource(ItemSource):
s3key_map = dict(
(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):
@@ -113,6 +129,9 @@ class S3ItemSource(ItemSource):
def update(self, item: S3Item) -> None:
pass
def is_authorized(self, descriptor):
return True
def lookup_item(self, descriptor):
full_path = self.url(descriptor)
if self.is_h5ad_url(full_path):
@@ -163,7 +182,9 @@ class S3ItemSource(ItemSource):
self.make_s3item_from_key(
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)
and self.s3.isfile("s3://" + annotation)
]
+9
View File
@@ -0,0 +1,9 @@
from prometheus_flask_exporter import PrometheusMetrics
from cellxgene_gateway import __version__
def add_metrics(app):
metrics = PrometheusMetrics(app)
metrics.info("app_info", "Application info", version=__version__)
return metrics
+1 -1
View File
@@ -18,7 +18,7 @@ logger = logging.getLogger(__name__)
class PruneProcessCache:
def __init__(self, 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):
while True:
+6 -3
View File
@@ -17,6 +17,8 @@ from cellxgene_gateway.dir_util import make_annotations
from cellxgene_gateway.env import cellxgene_args, enable_annotations, enable_backed_mode
from cellxgene_gateway.process_exception import ProcessException
logger = logging.getLogger(__name__)
class SubprocessBackend:
def __init__(self):
@@ -55,7 +57,7 @@ class SubprocessBackend:
scripts,
cache_entry.key.annotation_file_path,
)
logging.getLogger("cellxgene_gateway").info(f"launching {cmd}")
logger.info(f"launching {cmd}")
process = subprocess.Popen(
[cmd], stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True
)
@@ -84,5 +86,6 @@ class SubprocessBackend:
cache_entry.append_output(output)
cache_entry.set_loaded(process.pid)
return
for output in process.communicate():
logger.debug(f"cellxgene:{output}")
logger.info(f"exiting {cmd}")
@@ -61,7 +61,7 @@
<td>
{% if entry.status.name == 'loaded' %}
<a
href="{{ url_for('do_terminate', path=entry.key.descriptor, source_name=entry.key.source_name) }}">
href="{{ url_for('gateway_blueprint.do_terminate', path=entry.key.descriptor, source_name=entry.key.source_name) }}">
terminate </a>
{% endif %}
</td>
@@ -75,7 +75,9 @@
const el = $(this);
const ts = el.text();
const dt = new Date(parseInt(ts * 1000));
el.html(`${dt.toISOString()}<br>(${ts})`);
el.prepend(`${dt.toISOString()}<br>(`);
el.append(')');
});
})
</script>
@@ -28,11 +28,11 @@
<h4>{{ message }}</h4>
<a href="{{ url_for('filecrawl') }}">
<a href="{{ url_for('gateway_blueprint.filecrawl') }}">
Please click here to be redirected to the file directory.
</a>
<br>
<a href="{{ url_for('index') }}">
<a href="{{ url_for('gateway_blueprint.index') }}">
Please click here to return to the homepage.
</a>
</div>
+2 -2
View File
@@ -36,10 +36,10 @@
Navigation:
<ul>
{% if path %}
<li><a href="{{ url_for('filecrawl') }}">top level</a></li>
<li><a href="{{ url_for('gateway_blueprint.filecrawl') }}">top level</a></li>
{% else %}
{% endif %}
<li><a href="{{ url_for('index') }}">homepage</a></li>
<li><a href="{{ url_for('gateway_blueprint.index') }}">homepage</a></li>
</ul>
</p>
<script>
+2 -2
View File
@@ -35,12 +35,12 @@
Links:
</h1>
<div class="list-group" style="width:50%;padding-left:65px">
<a href="{{ url_for('filecrawl') }}" class="list-group-item list-group-item-action">
<a href="{{ url_for('gateway_blueprint.filecrawl') }}" class="list-group-item list-group-item-action">
<u>File Crawler: Allows you to view all uploaded data.</u></a>
</div>
<div class="list-group" style="width:50%;padding-left:65px">
<a href="{{ url_for('do_GET_status') }}" class="list-group-item list-group-item-action">
<a href="{{ url_for('gateway_blueprint.do_GET_status') }}" class="list-group-item list-group-item-action">
<u>Cache Status: view status of launched cellxgene servers.</u></a>
</div>
+2 -2
View File
@@ -35,11 +35,11 @@
The page will refresh shortly.
</p>
<a href="{{ url_for('filecrawl') }}">
<a href="{{ url_for('gateway_blueprint.filecrawl') }}">
Please click here to be redirected to the file directory.
</a>
<br>
<a href="{{ url_for('index') }}">
<a href="{{ url_for('gateway_blueprint.index') }}">
Please click here to return to the homepage.
</a>
</div>
@@ -39,10 +39,10 @@
<li><a href="{{ relaunch_url }}">
Attempt to relaunch the cellxgene server.
</a></li>
<li><a href="{{ url_for('filecrawl') }}">
<li><a href="{{ url_for('gateway_blueprint.filecrawl') }}">
Return to the file directory.
</a></li>
<li><a href="{{ url_for('index') }}">
<li><a href="{{ url_for('gateway_blueprint.index') }}">
Return to the homepage.
</a></li>
</ul>
+8 -2
View File
@@ -2,13 +2,19 @@ name: cellxgene-gateway
channels:
- conda-forge
dependencies:
- python=3.7
- python=3.9
- requests
- flask
- psutil
- black
- typer
- twine
- isort
- coverage
- pip
- pip:
- pre_commit
- flask-api
- cellxgene>=0.15
- werkzeug
- cellxgene
- prometheus-flask-exporter
+9 -5
View File
@@ -1,5 +1,9 @@
cellxgene>=0.15
flask
flask_api
psutil
requests
# requirements.txt
#
# installs dependencies from ./setup.py, and the package itself,
# in editable mode
-e .[prometheus]
# (the -e above is optional). you could also just install the package
# normally with just the line below (after uncommenting)
# .
+29 -1
View File
@@ -1,2 +1,30 @@
[metadata]
description-file = README.md
description_file = README.md
description = "Cellxgene Gateway"
author = "Niket Patel, Yohann Potier, Alok Saldanha"
author_email = "alok.saldanha@novartis.com"
long_description_content_type="text/markdown"
license = "MIT"
keywords ="visualization, genomics"
url = "http://github.com/Novartis/cellxgene-gateway"
python_requires = ">=3.6"
classifier =
"Topic :: Scientific/Engineering :: Visualization"
[options]
install_requires =
cellxgene
flask
flask-api
werkzeug
psutil
requests
typer
[options.extras_require]
prometheus =
prometheus-flask-exporter
[entry_points]
console_scripts =
cellxgene-gateway = cellxgene_gateway.cli:run
+1 -28
View File
@@ -4,9 +4,6 @@ import sys
from setuptools import find_packages, setup
if sys.version_info < (3, 6):
sys.exit("Sorry, Python < 3.6 is not supported")
def read(rel_path):
here = os.path.abspath(os.path.dirname(__file__))
@@ -23,33 +20,15 @@ def get_version(rel_path):
raise RuntimeError("Unable to find version string.")
def parse_requirements():
reqs = []
with open("requirements.txt", "r") as f:
for line in f.readlines():
reqs.append(line.strip("\n"))
return reqs
with open("README.md", "r") as fh:
long_description = fh.read()
install_reqs = parse_requirements()
setup(
# mandatory
name="cellxgene-gateway",
# mandatory
version=get_version("cellxgene_gateway/__init__.py"),
# mandatory
author="Niket Patel, Yohann Potier, Alok Saldanha",
author_email="alok.saldanha@novartis.com",
description=("Cellxgene Gateway"),
long_description=long_description,
long_description_content_type="text/markdown",
license="MIT",
keywords="visualization, genomics",
url="http://github.com/Novartis/cellxgene-gateway",
name="cellxgene-gateway",
packages=find_packages(),
package_data={
"cellxgene_gateway": [
@@ -60,10 +39,4 @@ setup(
]
},
data_files=[("", ["README.md", "LICENSE"])],
install_requires=install_reqs,
entry_points={
"console_scripts": ["cellxgene-gateway=cellxgene_gateway.gateway:main"]
},
classifiers=["Topic :: Scientific/Engineering :: Visualization"],
python_requires=">=3.6",
)
+9 -2
View File
@@ -1,6 +1,9 @@
import unittest
from unittest.mock import MagicMock, Mock, patch
from flask import Flask
from cellxgene_gateway.gateway_blueprint import gateway_blueprint
from cellxgene_gateway.items.item import ItemType
from cellxgene_gateway.items.s3.s3item import S3Item
from cellxgene_gateway.items.s3.s3item_source import S3ItemSource
@@ -38,7 +41,8 @@ class TestScanDirectory(unittest.TestCase):
return True
raise Exception("exists called with " + path)
def ls(path):
def ls(path, refresh):
assert refresh == True
if path == "s3://my-bucket/":
return [
"my-bucket/lvl1",
@@ -80,7 +84,10 @@ class TestScanDirectory(unittest.TestCase):
s3func.return_value = S3Mock
source = S3ItemSource("my-bucket")
tree = source.scan_directory()
app = Flask(__name__)
app.register_blueprint(gateway_blueprint)
with app.test_request_context(query_string="refresh=true") as test_context:
tree = source.scan_directory()
def s3item_compare(i1, i2, msg=""):
self.assertEqual(i1.name, i2.name, "name equals")
+3 -2
View File
@@ -5,7 +5,7 @@ from flask import Flask
from cellxgene_gateway import flask_util
from cellxgene_gateway.cache_entry import CacheEntry, CacheEntryStatus
from cellxgene_gateway.cache_key import CacheKey
from cellxgene_gateway.gateway import app
from cellxgene_gateway.gateway_blueprint import gateway_blueprint
from cellxgene_gateway.items.file.fileitem import FileItem
from cellxgene_gateway.items.file.fileitem_source import FileItemSource
from cellxgene_gateway.items.item import ItemType
@@ -18,7 +18,8 @@ key = CacheKey(
class TestRenderEntry(unittest.TestCase):
def setUp(self):
self.app = app
self.app = Flask(__name__)
self.app.register_blueprint(gateway_blueprint)
self.app_context = self.app.test_request_context()
self.app_context.push()
self.client = self.app.test_client()
+17 -1
View File
@@ -1,7 +1,11 @@
import unittest
from unittest.mock import MagicMock, 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_source import FileItemSource
from cellxgene_gateway.items.item import ItemTree, ItemType
@@ -41,3 +45,15 @@ class TestRenderItemSource(unittest.TestCase):
rendered,
"<h6><a href='/filecrawl.html?source=FakeSource'>FakeSource</a>:some_filter</h6><li><a href='/filecrawl/rootdir?source=FakeSource'>rootdir</a><ul></ul></li>",
)
class TestRenderItemTree(unittest.TestCase):
@patch("cellxgene_gateway.items.file.fileitem_source.FileItemSource")
def test_GIVEN_deep_nested_dirs_THEN_includes_dirs_in_output(self, item_source):
item_source.name = "FakeSource"
item_tree = ItemTree("foo/bar/baz", [], [])
rendered = render_item_tree(item_tree, item_source)
self.assertEqual(
rendered,
"<li><a href='/filecrawl/foo/bar/baz?source=FakeSource'>baz</a><ul></ul></li>",
)
+1 -1
View File
@@ -15,7 +15,7 @@ key = CacheKey(
class TestPruneProcessCache(unittest.TestCase):
@patch("cellxgene_gateway.util.current_time_stamp", new=lambda: 0)
@patch("cellxgene_gateway.env.ttl", new="10")
@patch("cellxgene_gateway.env.expire_seconds", new=10)
@patch("cellxgene_gateway.cache_entry.CacheEntry")
@patch("cellxgene_gateway.cache_entry.CacheEntry")
def test_GIVEN_one_old_one_new_THEN_prune_old(self, old, new):