mirror of
https://github.com/Novartis/cellxgene-gateway.git
synced 2026-09-17 05:47:56 +08:00
Compare commits
37 Commits
nested_sub
...
79_add_doc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f296efcc55 | ||
|
|
facfb27d5c | ||
|
|
390fe24ea4 | ||
|
|
590565bea2 | ||
|
|
d32a31e855 | ||
|
|
0cd551382e | ||
|
|
36c0a4d3d7 | ||
|
|
8d8a0a3483 | ||
|
|
eaa157079c | ||
|
|
977c50ce8c | ||
|
|
833cad3bc2 | ||
|
|
c5f3c68740 | ||
|
|
d944a31d59 | ||
|
|
5814cb9943 | ||
|
|
9a91cdf795 | ||
|
|
25aff5c020 | ||
|
|
6bcb594712 | ||
|
|
f9ed4c4047 | ||
|
|
a9753c4101 | ||
|
|
fd48920c5b | ||
|
|
09db93b2b5 | ||
|
|
3e3bd22512 | ||
|
|
893b2f1af1 | ||
|
|
757487b772 | ||
|
|
87a8dbfa78 | ||
|
|
9c38e48c5c | ||
|
|
073f5f945c | ||
|
|
9dc4409f1a | ||
|
|
551cb46af8 | ||
|
|
620181ae4d | ||
|
|
73a7920cc8 | ||
|
|
ed3e999cd1 | ||
|
|
2ae2e53863 | ||
|
|
fd0e7d9c31 | ||
|
|
98ef6efd0c | ||
|
|
82e43ff943 | ||
|
|
f8a77423eb |
1
.github/workflows/pr-checks.yaml
vendored
1
.github/workflows/pr-checks.yaml
vendored
@@ -39,6 +39,7 @@ jobs:
|
|||||||
conda env create -f environment.yml
|
conda env create -f environment.yml
|
||||||
eval "$(conda shell.bash hook)"
|
eval "$(conda shell.bash hook)"
|
||||||
conda activate cellxgene-gateway
|
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
|
python setup.py install
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ repos:
|
|||||||
types: [python]
|
types: [python]
|
||||||
stages: [commit]
|
stages: [commit]
|
||||||
- id: black
|
- id: black
|
||||||
language_version: python3.6+
|
|
||||||
name: black
|
name: black
|
||||||
language: system
|
language: system
|
||||||
entry: black
|
entry: black
|
||||||
|
|||||||
31
Changelog.md
31
Changelog.md
@@ -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
|
# 0.3.3
|
||||||
|
|
||||||
* Fixed bug #48 affecting cache pruning
|
* Fixed bug #48 affecting cache pruning
|
||||||
|
|||||||
8
Dockerfile
Normal file
8
Dockerfile
Normal 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"]
|
||||||
30
README.md
30
README.md
@@ -73,9 +73,12 @@ Optional environment variables:
|
|||||||
* `EXTERNAL_PROTOCOL` - typically http when running locally, can be https when deployed if the gateway is behind a load balancer or reverse proxy that performs https termination. Default value "http"
|
* `EXTERNAL_PROTOCOL` - typically http when running locally, can be https when deployed if the gateway is behind a load balancer or reverse proxy that performs https termination. Default value "http"
|
||||||
* `GATEWAY_IP` - ip addess of instance gateway is running on, mostly used to display SSH instructions. Defaults to `socket.gethostbyname(socket.gethostname())`
|
* `GATEWAY_IP` - ip addess of instance gateway is running on, mostly used to display SSH instructions. Defaults to `socket.gethostbyname(socket.gethostname())`
|
||||||
* `GATEWAY_PORT` - local port that the gateway should bind to, defaults to 5005
|
* `GATEWAY_PORT` - local port that the gateway should bind to, defaults to 5005
|
||||||
|
* `GATEWAY_EXPIRE_SECONDS` - time in seconds that a cellxgene process will remain idle before being terminated. Defaults to 3600 (one hour)
|
||||||
* `GATEWAY_EXTRA_SCRIPTS` - JSON array of script paths, will be embedded into each page and forwarded with `--scripts` to cellxgene server
|
* `GATEWAY_EXTRA_SCRIPTS` - JSON array of script paths, will be embedded into each page and forwarded with `--scripts` to cellxgene server
|
||||||
* `GATEWAY_ENABLE_ANNOTATIONS` - Set to `true` or to `1` to enable cellxgene annotations.
|
* `GATEWAY_ENABLE_ANNOTATIONS` - Set to `true` or to `1` to enable cellxgene annotations.
|
||||||
* `GATEWAY_ENABLE_BACKED_MODE` - Set to `true` or to `1` to load AnnData in file-backed mode. This saves memory and speeds up launch time but may reduce overall performance.
|
* `GATEWAY_ENABLE_BACKED_MODE` - Set to `true` or to `1` to load AnnData in file-backed mode. This saves memory and speeds up launch time but may reduce overall performance.
|
||||||
|
* `GATEWAY_LOG_LEVEL` - default is `INFO`. set to `DEBUG` to increase logging and to `WARNING` to decrease logging.
|
||||||
|
* `S3_ENABLE_LISTINGS_CACHE` - Set to `true` or to `1` to cache listings of S3 folders for performance. If the cache becomes stale, set `filecrawl.html?refresh=true` query parameter to refresh the cache.
|
||||||
|
|
||||||
If any of the following optional variables are set, [ProxyFix](https://werkzeug.palletsprojects.com/en/1.0.x/middleware/proxy_fix/) will be used.
|
If any of the following optional variables are set, [ProxyFix](https://werkzeug.palletsprojects.com/en/1.0.x/middleware/proxy_fix/) will be used.
|
||||||
* `PROXY_FIX_FOR` - Number of upstream proxies setting X-Forwarded-For
|
* `PROXY_FIX_FOR` - Number of upstream proxies setting X-Forwarded-For
|
||||||
@@ -86,6 +89,33 @@ If any of the following optional variables are set, [ProxyFix](https://werkzeug.
|
|||||||
|
|
||||||
The defaults should be fine if you set up a venv and cellxgene_data folder as above.
|
The defaults should be fine if you set up a venv and cellxgene_data folder as above.
|
||||||
|
|
||||||
|
## Running cellxgene-gateway with Docker
|
||||||
|
|
||||||
|
First, build Docker image:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t cellxgene-gateway .
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, cellxgene-gateway can be launched as such:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -it --rm \
|
||||||
|
-v <local_data_dir>:/cellxgene-data \
|
||||||
|
-p 5005:5005 \
|
||||||
|
cellxgene-gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
Additional environment variables can be provided with the `-e` parameter:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -it --rm \
|
||||||
|
-v <local_data_dir>:/cellxgene-data \
|
||||||
|
-e GATEWAY_PORT=8080 \
|
||||||
|
-p 8080:8080 \
|
||||||
|
cellxgene-gateway
|
||||||
|
```
|
||||||
|
|
||||||
# Customization
|
# Customization
|
||||||
|
|
||||||
The current paradigm for customization is to modify files during a build or deployment phase:
|
The current paradigm for customization is to modify files during a build or deployment phase:
|
||||||
|
|||||||
@@ -7,4 +7,4 @@
|
|||||||
# OR CONDITIONS OF ANY KIND, either express or implied. See the License for
|
# OR CONDITIONS OF ANY KIND, either express or implied. See the License for
|
||||||
# the specific language governing permissions and limitations under the License.
|
# the specific language governing permissions and limitations under the License.
|
||||||
|
|
||||||
__version__ = "0.3.3"
|
__version__ = "0.3.10"
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import urllib.parse
|
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
import psutil
|
import psutil
|
||||||
@@ -22,6 +21,8 @@ from cellxgene_gateway.cellxgene_exception import CellxgeneException
|
|||||||
from cellxgene_gateway.flask_util import querystring
|
from cellxgene_gateway.flask_util import querystring
|
||||||
from cellxgene_gateway.util import current_time_stamp
|
from cellxgene_gateway.util import current_time_stamp
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class CacheEntryStatus(Enum):
|
class CacheEntryStatus(Enum):
|
||||||
loaded = "loaded"
|
loaded = "loaded"
|
||||||
@@ -111,7 +112,7 @@ class CacheEntry:
|
|||||||
except psutil.NoSuchProcess:
|
except psutil.NoSuchProcess:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
logging.getLogger("cellxgene_gateway").info(f"terminated {terminated}")
|
logger.info(f"terminated {terminated}")
|
||||||
self.status = CacheEntryStatus.terminated
|
self.status = CacheEntryStatus.terminated
|
||||||
|
|
||||||
def rewrite_text_content(self, cellxgene_content):
|
def rewrite_text_content(self, cellxgene_content):
|
||||||
@@ -169,38 +170,43 @@ class CacheEntry:
|
|||||||
|
|
||||||
full_path = self.cellxgene_basepath() + subpath + querystring()
|
full_path = self.cellxgene_basepath() + subpath + querystring()
|
||||||
|
|
||||||
if request.method in ["GET", "HEAD", "OPTIONS"]:
|
try:
|
||||||
cellxgene_response = get(full_path, headers=headers)
|
cellxgene_response = None
|
||||||
elif request.method == "PUT":
|
if request.method in ["GET", "HEAD", "OPTIONS"]:
|
||||||
cellxgene_response = put(
|
cellxgene_response = get(full_path, headers=headers)
|
||||||
full_path,
|
elif request.method == "PUT":
|
||||||
headers=headers,
|
cellxgene_response = put(
|
||||||
data=request.data,
|
full_path,
|
||||||
)
|
headers=headers,
|
||||||
elif request.method == "POST":
|
data=request.data,
|
||||||
cellxgene_response = post(
|
)
|
||||||
full_path,
|
elif request.method == "POST":
|
||||||
headers=headers,
|
cellxgene_response = post(
|
||||||
data=request.data,
|
full_path,
|
||||||
)
|
headers=headers,
|
||||||
else:
|
data=request.data,
|
||||||
raise CellxgeneException(f"Unexpected method {request.method}", 400)
|
)
|
||||||
content_type = cellxgene_response.headers["content-type"]
|
else:
|
||||||
if "text" in content_type:
|
raise CellxgeneException(f"Unexpected method {request.method}", 400)
|
||||||
gateway_content = self.rewrite_text_content(
|
content_type = cellxgene_response.headers["content-type"]
|
||||||
cellxgene_response.content.decode()
|
if "text" in content_type:
|
||||||
)
|
gateway_content = self.rewrite_text_content(
|
||||||
else:
|
cellxgene_response.content.decode()
|
||||||
gateway_content = cellxgene_response.content
|
)
|
||||||
|
else:
|
||||||
|
gateway_content = cellxgene_response.content
|
||||||
|
|
||||||
resp_headers = {}
|
resp_headers = {}
|
||||||
for h in copy_headers:
|
for h in copy_headers:
|
||||||
if h in cellxgene_response.headers:
|
if h in cellxgene_response.headers:
|
||||||
resp_headers[h] = cellxgene_response.headers[h]
|
resp_headers[h] = cellxgene_response.headers[h]
|
||||||
|
|
||||||
gateway_response = make_response(
|
gateway_response = make_response(
|
||||||
gateway_content,
|
gateway_content,
|
||||||
cellxgene_response.status_code,
|
cellxgene_response.status_code,
|
||||||
resp_headers,
|
resp_headers,
|
||||||
)
|
)
|
||||||
|
finally:
|
||||||
|
if cellxgene_response is not None:
|
||||||
|
cellxgene_response.close()
|
||||||
return gateway_response
|
return gateway_response
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import socket
|
|
||||||
|
|
||||||
cellxgene_location = os.environ.get("CELLXGENE_LOCATION")
|
cellxgene_location = os.environ.get("CELLXGENE_LOCATION")
|
||||||
cellxgene_data = os.environ.get("CELLXGENE_DATA", "")
|
cellxgene_data = os.environ.get("CELLXGENE_DATA", "")
|
||||||
@@ -24,7 +23,9 @@ external_protocol = os.environ.get(
|
|||||||
)
|
)
|
||||||
ip = os.environ.get("GATEWAY_IP")
|
ip = os.environ.get("GATEWAY_IP")
|
||||||
extra_scripts = os.environ.get("GATEWAY_EXTRA_SCRIPTS")
|
extra_scripts = os.environ.get("GATEWAY_EXTRA_SCRIPTS")
|
||||||
ttl = os.environ.get("GATEWAY_TTL")
|
expire_seconds = int(
|
||||||
|
os.environ.get("GATEWAY_EXPIRE_SECONDS", os.environ.get("GATEWAY_TTL", "3600"))
|
||||||
|
)
|
||||||
enable_annotations = os.environ.get("GATEWAY_ENABLE_ANNOTATIONS", "").lower() in [
|
enable_annotations = os.environ.get("GATEWAY_ENABLE_ANNOTATIONS", "").lower() in [
|
||||||
"true",
|
"true",
|
||||||
"1",
|
"1",
|
||||||
@@ -33,6 +34,7 @@ enable_backed_mode = os.environ.get("GATEWAY_ENABLE_BACKED_MODE", "").lower() in
|
|||||||
"true",
|
"true",
|
||||||
"1",
|
"1",
|
||||||
]
|
]
|
||||||
|
log_level = logging.getLevelName(os.environ.get("GATEWAY_LOG_LEVEL", "INFO"))
|
||||||
|
|
||||||
env_vars = {
|
env_vars = {
|
||||||
"CELLXGENE_LOCATION": cellxgene_location,
|
"CELLXGENE_LOCATION": cellxgene_location,
|
||||||
@@ -50,9 +52,10 @@ optional_env_vars = {
|
|||||||
"GATEWAY_IP": ip,
|
"GATEWAY_IP": ip,
|
||||||
"GATEWAY_PORT": gateway_port,
|
"GATEWAY_PORT": gateway_port,
|
||||||
"GATEWAY_EXTRA_SCRIPTS": extra_scripts,
|
"GATEWAY_EXTRA_SCRIPTS": extra_scripts,
|
||||||
"GATEWAY_TTL": ttl,
|
"GATEWAY_EXPIRE_SECONDS": expire_seconds,
|
||||||
"GATEWAY_ENABLE_ANNOTATIONS": enable_annotations,
|
"GATEWAY_ENABLE_ANNOTATIONS": enable_annotations,
|
||||||
"GATEWAY_ENABLE_BACKED_MODE": enable_backed_mode,
|
"GATEWAY_ENABLE_BACKED_MODE": enable_backed_mode,
|
||||||
|
"GATEWAY_LOG_LEVEL": log_level,
|
||||||
"CELLXGENE_ARGS": cellxgene_args,
|
"CELLXGENE_ARGS": cellxgene_args,
|
||||||
"CELLXGENE_DATA": cellxgene_data,
|
"CELLXGENE_DATA": cellxgene_data,
|
||||||
"PROXY_FIX_FOR": proxy_fix_for,
|
"PROXY_FIX_FOR": proxy_fix_for,
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ def render_item_tree(item_tree, item_source):
|
|||||||
if item_tree.descriptor:
|
if item_tree.descriptor:
|
||||||
descriptor = item_tree.descriptor.lstrip("/")
|
descriptor = item_tree.descriptor.lstrip("/")
|
||||||
url = f"/filecrawl/{descriptor}?source={item_source.name}"
|
url = f"/filecrawl/{descriptor}?source={item_source.name}"
|
||||||
name = descriptor.rsplit("/")[1] if descriptor.find("/") >= 0 else descriptor
|
name = descriptor.rsplit("/", 1)[-1]
|
||||||
return f"<li><a href='{url}'>{name}</a>{html}</li>"
|
return f"<li><a href='{url}'>{name}</a>{html}</li>"
|
||||||
else:
|
else:
|
||||||
return html
|
return html
|
||||||
|
|||||||
@@ -22,9 +22,7 @@ from flask import (
|
|||||||
send_from_directory,
|
send_from_directory,
|
||||||
url_for,
|
url_for,
|
||||||
)
|
)
|
||||||
from flask_api import status
|
|
||||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||||
from werkzeug.utils import secure_filename
|
|
||||||
|
|
||||||
from cellxgene_gateway import env, flask_util
|
from cellxgene_gateway import env, flask_util
|
||||||
from cellxgene_gateway.backend_cache import BackendCache
|
from cellxgene_gateway.backend_cache import BackendCache
|
||||||
@@ -52,6 +50,14 @@ def _force_https(app):
|
|||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def set_no_cache(resp):
|
||||||
|
resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
||||||
|
resp.headers["Pragma"] = "no-cache"
|
||||||
|
resp.headers["Expires"] = "0"
|
||||||
|
resp.headers["Cache-Control"] = "public, max-age=0"
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
app.wsgi_app = _force_https(app.wsgi_app)
|
app.wsgi_app = _force_https(app.wsgi_app)
|
||||||
if (
|
if (
|
||||||
env.proxy_fix_for > 0
|
env.proxy_fix_for > 0
|
||||||
@@ -157,10 +163,7 @@ def filecrawl(path=None):
|
|||||||
path=path,
|
path=path,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
set_no_cache(resp)
|
||||||
resp.headers["Pragma"] = "no-cache"
|
|
||||||
resp.headers["Expires"] = "0"
|
|
||||||
resp.headers["Cache-Control"] = "public, max-age=0"
|
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|
||||||
@@ -209,7 +212,10 @@ def do_view(path, source_name=None):
|
|||||||
match.status == CacheEntryStatus.loaded
|
match.status == CacheEntryStatus.loaded
|
||||||
or match.status == CacheEntryStatus.loading
|
or match.status == CacheEntryStatus.loading
|
||||||
):
|
):
|
||||||
return match.serve_content(path)
|
if source.is_authorized(match.key.descriptor):
|
||||||
|
return match.serve_content(path)
|
||||||
|
else:
|
||||||
|
raise CellxgeneException("User not authorized to access this data", 403)
|
||||||
elif match.status == CacheEntryStatus.error:
|
elif match.status == CacheEntryStatus.error:
|
||||||
raise ProcessException.from_cache_entry(match)
|
raise ProcessException.from_cache_entry(match)
|
||||||
|
|
||||||
@@ -267,6 +273,12 @@ def do_terminate(path):
|
|||||||
return redirect(url_for("do_GET_status"), code=302)
|
return redirect(url_for("do_GET_status"), code=302)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/metadata/ip_address", methods=["GET"])
|
||||||
|
def ip_address():
|
||||||
|
resp = make_response(env.ip)
|
||||||
|
return set_no_cache(resp)
|
||||||
|
|
||||||
|
|
||||||
def launch():
|
def launch():
|
||||||
env.validate()
|
env.validate()
|
||||||
if not item_sources or not len(item_sources):
|
if not item_sources or not len(item_sources):
|
||||||
@@ -287,7 +299,7 @@ def launch():
|
|||||||
|
|
||||||
def main():
|
def main():
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=env.log_level,
|
||||||
format="%(asctime)s:%(name)s:%(levelname)s:%(message)s",
|
format="%(asctime)s:%(name)s:%(levelname)s:%(message)s",
|
||||||
)
|
)
|
||||||
cellxgene_data = os.environ.get("CELLXGENE_DATA", None)
|
cellxgene_data = os.environ.get("CELLXGENE_DATA", None)
|
||||||
|
|||||||
@@ -121,6 +121,9 @@ class FileItemSource(ItemSource):
|
|||||||
if self.is_h5ad_file(full_path):
|
if self.is_h5ad_file(full_path):
|
||||||
return self.shallowitem_from_descriptor(descriptor)
|
return self.shallowitem_from_descriptor(descriptor)
|
||||||
|
|
||||||
|
def is_authorized(self, descriptor):
|
||||||
|
return True
|
||||||
|
|
||||||
def lookup(self, indescriptor: str) -> LookupResult:
|
def lookup(self, indescriptor: str) -> LookupResult:
|
||||||
descriptor = indescriptor.strip("/")
|
descriptor = indescriptor.strip("/")
|
||||||
if descriptor.endswith(self.annotation_file_suffix):
|
if descriptor.endswith(self.annotation_file_suffix):
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ class ItemSource(ABC):
|
|||||||
def update(self, item: Item) -> None:
|
def update(self, item: Item) -> None:
|
||||||
raise Exception('"update" unimplemented')
|
raise Exception('"update" unimplemented')
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def is_authorized(self, descriptor: str) -> bool:
|
||||||
|
raise Exception('"is_authorized" unimplemented')
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def lookup(self, descriptor: str) -> LookupResult:
|
def lookup(self, descriptor: str) -> LookupResult:
|
||||||
raise Exception('"lookup" unimplemented')
|
raise Exception('"lookup" unimplemented')
|
||||||
|
|||||||
@@ -7,9 +7,11 @@
|
|||||||
# OR CONDITIONS OF ANY KIND, either express or implied. See the License for
|
# OR CONDITIONS OF ANY KIND, either express or implied. See the License for
|
||||||
# the specific language governing permissions and limitations under the License.
|
# the specific language governing permissions and limitations under the License.
|
||||||
|
|
||||||
from os.path import basename, dirname, join
|
import os
|
||||||
|
from os.path import basename, dirname
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
|
import flask
|
||||||
import s3fs
|
import s3fs
|
||||||
|
|
||||||
from cellxgene_gateway import dir_util
|
from cellxgene_gateway import dir_util
|
||||||
@@ -18,6 +20,10 @@ from cellxgene_gateway.items.item_source import ItemSource, LookupResult
|
|||||||
from cellxgene_gateway.items.s3.s3item import S3Item
|
from cellxgene_gateway.items.s3.s3item import S3Item
|
||||||
|
|
||||||
|
|
||||||
|
def truthy(val: str):
|
||||||
|
return val.lower() in ["true", "1"]
|
||||||
|
|
||||||
|
|
||||||
class S3ItemSource(ItemSource):
|
class S3ItemSource(ItemSource):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -28,7 +34,10 @@ class S3ItemSource(ItemSource):
|
|||||||
annotation_file_suffix=".csv",
|
annotation_file_suffix=".csv",
|
||||||
):
|
):
|
||||||
self._name = name
|
self._name = name
|
||||||
self.s3 = s3fs.S3FileSystem()
|
enable_cache = os.environ.get("S3_ENABLE_LISTINGS_CACHE", "false").lower()
|
||||||
|
assert enable_cache in ["0", "1", "false", "true"]
|
||||||
|
self.use_listings_cache = truthy(enable_cache)
|
||||||
|
self.s3 = s3fs.S3FileSystem(use_listings_cache=self.use_listings_cache)
|
||||||
if bucket.startswith("s3://"):
|
if bucket.startswith("s3://"):
|
||||||
raise Exception(
|
raise Exception(
|
||||||
f"Bucket name should not include s3:// prefix, got {bucket}"
|
f"Bucket name should not include s3:// prefix, got {bucket}"
|
||||||
@@ -67,6 +76,13 @@ class S3ItemSource(ItemSource):
|
|||||||
item_tree = self.scan_directory("" if filter is None else filter)
|
item_tree = self.scan_directory("" if filter is None else filter)
|
||||||
return item_tree
|
return item_tree
|
||||||
|
|
||||||
|
@property
|
||||||
|
def refresh(self):
|
||||||
|
return (
|
||||||
|
truthy(flask.request.args.get("refresh", default="false"))
|
||||||
|
or not self.use_listings_cache
|
||||||
|
)
|
||||||
|
|
||||||
def scan_directory(self, directory_key="") -> dict:
|
def scan_directory(self, directory_key="") -> dict:
|
||||||
url = self.url(directory_key)
|
url = self.url(directory_key)
|
||||||
|
|
||||||
@@ -75,7 +91,7 @@ class S3ItemSource(ItemSource):
|
|||||||
|
|
||||||
s3key_map = dict(
|
s3key_map = dict(
|
||||||
(self.remove_bucket(filepath), "s3://" + filepath)
|
(self.remove_bucket(filepath), "s3://" + filepath)
|
||||||
for filepath in sorted(self.s3.ls(url))
|
for filepath in sorted(self.s3.ls(url, refresh=self.refresh))
|
||||||
)
|
)
|
||||||
|
|
||||||
def is_annotation_dir(dir_s3key):
|
def is_annotation_dir(dir_s3key):
|
||||||
@@ -113,6 +129,9 @@ class S3ItemSource(ItemSource):
|
|||||||
def update(self, item: S3Item) -> None:
|
def update(self, item: S3Item) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def is_authorized(self, descriptor):
|
||||||
|
return True
|
||||||
|
|
||||||
def lookup_item(self, descriptor):
|
def lookup_item(self, descriptor):
|
||||||
full_path = self.url(descriptor)
|
full_path = self.url(descriptor)
|
||||||
if self.is_h5ad_url(full_path):
|
if self.is_h5ad_url(full_path):
|
||||||
@@ -163,7 +182,9 @@ class S3ItemSource(ItemSource):
|
|||||||
self.make_s3item_from_key(
|
self.make_s3item_from_key(
|
||||||
basename(annotation), self.remove_bucket(annotation), True
|
basename(annotation), self.remove_bucket(annotation), True
|
||||||
)
|
)
|
||||||
for annotation in sorted(self.s3.ls(annotations_fullpath))
|
for annotation in sorted(
|
||||||
|
self.s3.ls(annotations_fullpath, refresh=self.refresh)
|
||||||
|
)
|
||||||
if annotation.endswith(self.annotation_file_suffix)
|
if annotation.endswith(self.annotation_file_suffix)
|
||||||
and self.s3.isfile("s3://" + annotation)
|
and self.s3.isfile("s3://" + annotation)
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ logger = logging.getLogger(__name__)
|
|||||||
class PruneProcessCache:
|
class PruneProcessCache:
|
||||||
def __init__(self, cache):
|
def __init__(self, cache):
|
||||||
self.cache = cache
|
self.cache = cache
|
||||||
self.expire_seconds = 3600 if env.ttl is None else int(env.ttl)
|
self.expire_seconds = env.expire_seconds
|
||||||
|
|
||||||
def __call__(self):
|
def __call__(self):
|
||||||
while True:
|
while True:
|
||||||
|
|||||||
@@ -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.env import cellxgene_args, enable_annotations, enable_backed_mode
|
||||||
from cellxgene_gateway.process_exception import ProcessException
|
from cellxgene_gateway.process_exception import ProcessException
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class SubprocessBackend:
|
class SubprocessBackend:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
@@ -55,7 +57,7 @@ class SubprocessBackend:
|
|||||||
scripts,
|
scripts,
|
||||||
cache_entry.key.annotation_file_path,
|
cache_entry.key.annotation_file_path,
|
||||||
)
|
)
|
||||||
logging.getLogger("cellxgene_gateway").info(f"launching {cmd}")
|
logger.info(f"launching {cmd}")
|
||||||
process = subprocess.Popen(
|
process = subprocess.Popen(
|
||||||
[cmd], stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True
|
[cmd], stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True
|
||||||
)
|
)
|
||||||
@@ -84,5 +86,6 @@ class SubprocessBackend:
|
|||||||
cache_entry.append_output(output)
|
cache_entry.append_output(output)
|
||||||
|
|
||||||
cache_entry.set_loaded(process.pid)
|
cache_entry.set_loaded(process.pid)
|
||||||
|
for output in process.communicate():
|
||||||
return
|
logger.debug(f"cellxgene:{output}")
|
||||||
|
logger.info(f"exiting {cmd}")
|
||||||
|
|||||||
@@ -75,7 +75,9 @@
|
|||||||
const el = $(this);
|
const el = $(this);
|
||||||
const ts = el.text();
|
const ts = el.text();
|
||||||
const dt = new Date(parseInt(ts * 1000));
|
const dt = new Date(parseInt(ts * 1000));
|
||||||
el.html(`${dt.toISOString()}<br>(${ts})`);
|
el.prepend(`${dt.toISOString()}<br>(`);
|
||||||
|
el.append(')');
|
||||||
|
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -2,13 +2,17 @@ name: cellxgene-gateway
|
|||||||
channels:
|
channels:
|
||||||
- conda-forge
|
- conda-forge
|
||||||
dependencies:
|
dependencies:
|
||||||
- python=3.7
|
- python=3.9
|
||||||
- requests
|
- requests
|
||||||
- flask
|
- flask
|
||||||
- psutil
|
- psutil
|
||||||
- black
|
- black
|
||||||
|
- twine
|
||||||
|
- isort
|
||||||
- coverage
|
- coverage
|
||||||
- pip
|
- pip
|
||||||
- pip:
|
- pip:
|
||||||
|
- pre_commit
|
||||||
- flask-api
|
- flask-api
|
||||||
- cellxgene>=0.15
|
- werkzeug
|
||||||
|
- cellxgene
|
||||||
|
|||||||
14
examples/customized_docker_image/Dockerfile
Normal file
14
examples/customized_docker_image/Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
FROM python:3.9
|
||||||
|
|
||||||
|
RUN pip install cellxgene-gateway 'MarkupSafe<2.1'
|
||||||
|
|
||||||
|
COPY customize_ui.sh customize_ui.sh
|
||||||
|
RUN CELLXGENE_GATEWAY_DIR=/usr/local/lib/python3.9/site-packages/cellxgene_gateway . ./customize_ui.sh
|
||||||
|
|
||||||
|
ENV CELLXGENE_DATA=/cellxgene-data
|
||||||
|
ENV CELLXGENE_LOCATION=/usr/local/bin/cellxgene
|
||||||
|
EXPOSE 5005
|
||||||
|
|
||||||
|
RUN mkdir /cellxgene-data
|
||||||
|
|
||||||
|
CMD ["cellxgene-gateway"]
|
||||||
14
examples/customized_docker_image/README.md
Normal file
14
examples/customized_docker_image/README.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Purpose
|
||||||
|
|
||||||
|
This is a simple example of how to make a small script to customize the UI of cellxgene-gateway. The script that does the customization is `customize_ui.sh`, it simply makes the main header green using CSS but you could do anything you want there (including adding more script tags, etc).
|
||||||
|
|
||||||
|
# Usage
|
||||||
|
|
||||||
|
```
|
||||||
|
docker build -t cellxgene_custom .
|
||||||
|
CELLXGENE_DATA=`pwd`/../../../cellxgene_data
|
||||||
|
docker run -p 5005:5005 --mount src=$CELLXGENE_DATA,target=/cellxgene-data,type=bind cellxgene_custom
|
||||||
|
```
|
||||||
|
|
||||||
|
If you now open http://localhost:5005 you should see a green cellxgene gateway header.
|
||||||
|
|
||||||
3
examples/customized_docker_image/customize_ui.sh
Normal file
3
examples/customized_docker_image/customize_ui.sh
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# make the header bright green
|
||||||
|
find "${CELLXGENE_GATEWAY_DIR}/templates" -name index.html -exec sed -i -e 's/<head>/<head>\
|
||||||
|
> <style> header h3 {color: #0F0;} <\/style>/g' {} \;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
cellxgene>=0.15
|
cellxgene
|
||||||
flask
|
flask
|
||||||
flask_api
|
flask-api
|
||||||
|
werkzeug
|
||||||
psutil
|
psutil
|
||||||
requests
|
requests
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import MagicMock, Mock, patch
|
from unittest.mock import MagicMock, Mock, patch
|
||||||
|
|
||||||
|
from cellxgene_gateway.gateway import app
|
||||||
from cellxgene_gateway.items.item import ItemType
|
from cellxgene_gateway.items.item import ItemType
|
||||||
from cellxgene_gateway.items.s3.s3item import S3Item
|
from cellxgene_gateway.items.s3.s3item import S3Item
|
||||||
from cellxgene_gateway.items.s3.s3item_source import S3ItemSource
|
from cellxgene_gateway.items.s3.s3item_source import S3ItemSource
|
||||||
@@ -38,7 +39,8 @@ class TestScanDirectory(unittest.TestCase):
|
|||||||
return True
|
return True
|
||||||
raise Exception("exists called with " + path)
|
raise Exception("exists called with " + path)
|
||||||
|
|
||||||
def ls(path):
|
def ls(path, refresh):
|
||||||
|
assert refresh == True
|
||||||
if path == "s3://my-bucket/":
|
if path == "s3://my-bucket/":
|
||||||
return [
|
return [
|
||||||
"my-bucket/lvl1",
|
"my-bucket/lvl1",
|
||||||
@@ -80,7 +82,8 @@ class TestScanDirectory(unittest.TestCase):
|
|||||||
|
|
||||||
s3func.return_value = S3Mock
|
s3func.return_value = S3Mock
|
||||||
source = S3ItemSource("my-bucket")
|
source = S3ItemSource("my-bucket")
|
||||||
tree = source.scan_directory()
|
with app.test_request_context(query_string="refresh=true") as test_context:
|
||||||
|
tree = source.scan_directory()
|
||||||
|
|
||||||
def s3item_compare(i1, i2, msg=""):
|
def s3item_compare(i1, i2, msg=""):
|
||||||
self.assertEqual(i1.name, i2.name, "name equals")
|
self.assertEqual(i1.name, i2.name, "name equals")
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import MagicMock, patch
|
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 import FileItem
|
||||||
from cellxgene_gateway.items.file.fileitem_source import FileItemSource
|
from cellxgene_gateway.items.file.fileitem_source import FileItemSource
|
||||||
from cellxgene_gateway.items.item import ItemTree, ItemType
|
from cellxgene_gateway.items.item import ItemTree, ItemType
|
||||||
@@ -41,3 +45,15 @@ class TestRenderItemSource(unittest.TestCase):
|
|||||||
rendered,
|
rendered,
|
||||||
"<h6><a href='/filecrawl.html?source=FakeSource'>FakeSource</a>:some_filter</h6><li><a href='/filecrawl/rootdir?source=FakeSource'>rootdir</a><ul></ul></li>",
|
"<h6><a href='/filecrawl.html?source=FakeSource'>FakeSource</a>:some_filter</h6><li><a href='/filecrawl/rootdir?source=FakeSource'>rootdir</a><ul></ul></li>",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRenderItemTree(unittest.TestCase):
|
||||||
|
@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>",
|
||||||
|
)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ key = CacheKey(
|
|||||||
|
|
||||||
class TestPruneProcessCache(unittest.TestCase):
|
class TestPruneProcessCache(unittest.TestCase):
|
||||||
@patch("cellxgene_gateway.util.current_time_stamp", new=lambda: 0)
|
@patch("cellxgene_gateway.util.current_time_stamp", new=lambda: 0)
|
||||||
@patch("cellxgene_gateway.env.ttl", new="10")
|
@patch("cellxgene_gateway.env.expire_seconds", new=10)
|
||||||
@patch("cellxgene_gateway.cache_entry.CacheEntry")
|
@patch("cellxgene_gateway.cache_entry.CacheEntry")
|
||||||
@patch("cellxgene_gateway.cache_entry.CacheEntry")
|
@patch("cellxgene_gateway.cache_entry.CacheEntry")
|
||||||
def test_GIVEN_one_old_one_new_THEN_prune_old(self, old, new):
|
def test_GIVEN_one_old_one_new_THEN_prune_old(self, old, new):
|
||||||
|
|||||||
Reference in New Issue
Block a user