mirror of
https://github.com/chanzuckerberg/cellxgene.git
synced 2026-10-04 19:28:12 +08:00
Merge branch 'main' into colinmegill/geneset-prototype
This commit is contained in:
@@ -1,5 +1,19 @@
|
|||||||
[bumpversion]
|
[bumpversion]
|
||||||
current_version = 0.16.0
|
current_version = 0.16.0
|
||||||
|
commit = True
|
||||||
|
# The below regex details an acceptable version number by naming the groups (major, minor, patch, prerel, and
|
||||||
|
# prerelversion) and also specifying the valid values for each group (integers, `\d+`, for major, minor, patch, and
|
||||||
|
# prerelversion and only `rc` as the acceptable value for prerel).
|
||||||
|
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<prerel>rc)\.(?P<prerelversion>\d+))?
|
||||||
|
serialize =
|
||||||
|
{major}.{minor}.{patch}-{prerel}.{prerelversion}
|
||||||
|
{major}.{minor}.{patch}
|
||||||
|
|
||||||
|
[bumpversion:part:prerel]
|
||||||
|
optional_value = release
|
||||||
|
values =
|
||||||
|
rc
|
||||||
|
release
|
||||||
|
|
||||||
[bumpversion:file:setup.py]
|
[bumpversion:file:setup.py]
|
||||||
search = version="{current_version}"
|
search = version="{current_version}"
|
||||||
|
|||||||
@@ -67,11 +67,6 @@ jobs:
|
|||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
with:
|
with:
|
||||||
path: cellxgene
|
path: cellxgene
|
||||||
- name: Checkout tools repo
|
|
||||||
uses: actions/checkout@v2
|
|
||||||
with:
|
|
||||||
repository: theislab/anndata
|
|
||||||
path: anndata
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
cd cellxgene
|
cd cellxgene
|
||||||
@@ -82,7 +77,7 @@ jobs:
|
|||||||
# 2. install cellxgene
|
# 2. install cellxgene
|
||||||
pip install --upgrade cellxgene
|
pip install --upgrade cellxgene
|
||||||
# 3. install anndata
|
# 3. install anndata
|
||||||
cd ../anndata && pip install -e .
|
pip install git+https://github.com/theislab/anndata
|
||||||
- name: Tests
|
- name: Tests
|
||||||
run: cd cellxgene && make unit-test ${{ matrix.test-suite }}
|
run: cd cellxgene && make unit-test ${{ matrix.test-suite }}
|
||||||
|
|
||||||
@@ -102,17 +97,11 @@ jobs:
|
|||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
with:
|
with:
|
||||||
path: cellxgene
|
path: cellxgene
|
||||||
- name: Checkout tools repo
|
|
||||||
uses: actions/checkout@v2
|
|
||||||
with:
|
|
||||||
repository: theislab/anndata
|
|
||||||
path: anndata
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
cd cellxgene
|
cd cellxgene
|
||||||
sed -i -E 's/^anndata[>=]=[0-9]+.[0-9]+.[0-9]+$/anndata/g' server/requirements.txt
|
sed -i -E 's/^anndata[>=]=[0-9]+.[0-9]+.[0-9]+$/anndata/g' server/requirements.txt
|
||||||
make pydist install-dist dev-env
|
make pydist install-dist dev-env
|
||||||
cd ../anndata
|
pip install git+https://github.com/theislab/anndata
|
||||||
pip install -e .
|
|
||||||
- name: Tests
|
- name: Tests
|
||||||
run: cd cellxgene && make unit-test ${{ matrix.test-suite }}
|
run: cd cellxgene && make unit-test ${{ matrix.test-suite }}
|
||||||
|
|||||||
@@ -99,22 +99,32 @@ pydist: build
|
|||||||
|
|
||||||
# RELEASE HELPERS
|
# RELEASE HELPERS
|
||||||
|
|
||||||
# create new version to commit to main
|
# Create new version to commit to main
|
||||||
.PHONY: release-stage-1
|
.PHONY: create-release-candidate
|
||||||
release-stage-1: dev-env bump clean-lite gen-package-lock
|
create-release-candidate: dev-env bump-version clean-lite gen-package-lock
|
||||||
@echo "Version bumped part:$(PART) and client built. Ready to commit and push"
|
@echo "Version bumped part:$(PART) and client built. Ready to commit and push"
|
||||||
|
|
||||||
# build dist and release to dev pypi
|
# Bump the release candidate version if needed (i.e. the previous release candidate had errors).
|
||||||
.PHONY: release-stage-2
|
.PHONY: recreate-release-candidate
|
||||||
release-stage-2: dev-env pydist twine
|
recreate-release-candidate: dev-env bump-release-candidate clean-lite gen-package-lock
|
||||||
|
@echo "Version bumped part:$(PART) and client built. Ready to commit and push"
|
||||||
|
|
||||||
|
# Build dist and release to Test PyPI
|
||||||
|
.PHONY: release-candidate-to-test-pypi
|
||||||
|
release-candidate-to-test-pypi: dev-env pydist twine
|
||||||
@echo "Dist built and uploaded to test.pypi.org"
|
@echo "Dist built and uploaded to test.pypi.org"
|
||||||
@echo "Test the install:"
|
@echo "Test the install:"
|
||||||
@echo " make install-release-test"
|
@echo " make install-release-test"
|
||||||
@echo "Then upload to Pypi prod:"
|
|
||||||
@echo " make twine-prod"
|
|
||||||
|
|
||||||
.PHONY: release-stage-final
|
# Build final dist (gets rid of the rc tag) and release final candidate to TestPyPI
|
||||||
release-stage-final: twine-prod
|
.PHONY: release-final-to-test-pypi
|
||||||
|
release-final-to-test-pypi: dev-env bump-release clean-lite gen-package-lock pydist twine
|
||||||
|
@echo "Final release dist built and uploaded to test.pypi.org"
|
||||||
|
@echo "Test the install:"
|
||||||
|
@echo " make install-release-test"
|
||||||
|
|
||||||
|
.PHONY: release-final
|
||||||
|
release-final: twine-prod
|
||||||
@echo "Release uploaded to pypi.org"
|
@echo "Release uploaded to pypi.org"
|
||||||
|
|
||||||
# DANGER: releases directly to prod
|
# DANGER: releases directly to prod
|
||||||
@@ -136,11 +146,22 @@ dev-env-client:
|
|||||||
dev-env-server:
|
dev-env-server:
|
||||||
pip install -r server/requirements-dev.txt
|
pip install -r server/requirements-dev.txt
|
||||||
|
|
||||||
# give PART=[major, minor, part] as param to make bump
|
# Set PART=[major, minor, patch] as param to make bump.
|
||||||
.PHONY: bump
|
# This will create a release candidate. (i.e. 0.16.1 -> 0.16.2-rc.0 for a patch bump)
|
||||||
bump:
|
.PHONY: bump-version
|
||||||
|
bump-version:
|
||||||
bumpversion --config-file .bumpversion.cfg $(PART)
|
bumpversion --config-file .bumpversion.cfg $(PART)
|
||||||
|
|
||||||
|
# Increments the release candidate version (i.e. 0.16.2-rc.1 -> 0.16.2-rc.2)
|
||||||
|
.PHONY: bump-release-candidate
|
||||||
|
bump-release-candidate:
|
||||||
|
bumpversion --config-file .bumpversion.cfg prerelversion --allow-dirty
|
||||||
|
|
||||||
|
# Finalizes the release candidate by removing the release candidate tag (i.e. 0.16.2-rc.2 -> 0.16.2).
|
||||||
|
.PHONY: bump-release
|
||||||
|
bump-release:
|
||||||
|
bumpversion --config-file .bumpversion.cfg prerel --allow-dirty
|
||||||
|
|
||||||
.PHONY: twine
|
.PHONY: twine
|
||||||
twine:
|
twine:
|
||||||
twine upload --repository-url https://test.pypi.org/legacy/ dist/*
|
twine upload --repository-url https://test.pypi.org/legacy/ dist/*
|
||||||
|
|||||||
@@ -79,23 +79,7 @@ This project was started with the sole goal of empowering the scientific communi
|
|||||||
|
|
||||||
If you believe you have found a security issue, we would appreciate notification. Please send email to <security@chanzuckerberg.com>.
|
If you believe you have found a security issue, we would appreciate notification. Please send email to <security@chanzuckerberg.com>.
|
||||||
|
|
||||||
# About
|
# Inspiration
|
||||||
|
|
||||||
### Core team
|
|
||||||
|
|
||||||
The current core team:
|
|
||||||
|
|
||||||
- Colin Megill, frontend & product design
|
|
||||||
- Bruce Martin, software engineer
|
|
||||||
- Sidney Bell, computational biologist
|
|
||||||
- Lia Prins, designer
|
|
||||||
- Severiano Badajoz, software engineer
|
|
||||||
|
|
||||||
We would also like to gratefully acknowledge contributions from past core team members:
|
|
||||||
|
|
||||||
- Charlotte Weaver, software engineer
|
|
||||||
|
|
||||||
### Inspiration
|
|
||||||
|
|
||||||
We've been heavily inspired by several other related single-cell visualization projects, including the [UCSC Cell Browswer](http://cells.ucsc.edu/), [Cytoscape](http://www.cytoscape.org/), [Xena](https://xena.ucsc.edu/), [ASAP](https://asap.epfl.ch/), [Gene Pattern](http://genepattern-notebook.org/), and many others. We hope to explore collaborations where useful as this community works together on improving interactive visualization for single-cell data.
|
We've been heavily inspired by several other related single-cell visualization projects, including the [UCSC Cell Browswer](http://cells.ucsc.edu/), [Cytoscape](http://www.cytoscape.org/), [Xena](https://xena.ucsc.edu/), [ASAP](https://asap.epfl.ch/), [Gene Pattern](http://genepattern-notebook.org/), and many others. We hope to explore collaborations where useful as this community works together on improving interactive visualization for single-cell data.
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
exports[`did launch page launched 1`] = `"<span style=\\"width: 185px; display: flex; overflow: hidden; justify-content: flex-start;\\"><span style=\\"overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex-shrink: 1; min-width: 5px;\\">pbm</span><span style=\\"position: relative; overflow: hidden; white-space: nowrap;\\"><span style=\\"color: transparent;\\">c3k</span><span style=\\"position: absolute; right: 0px; color: inherit;\\">c3k</span></span></span>"`;
|
exports[`did launch page launched 1`] = `"<span style=\\"max-width: 155px; display: flex; overflow: hidden; justify-content: flex-start;\\"><span style=\\"overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex-shrink: 1; min-width: 5px;\\">pbm</span><span style=\\"position: relative; overflow: hidden; white-space: nowrap;\\"><span style=\\"color: transparent;\\">c3k</span><span style=\\"position: absolute; right: 0px; color: inherit;\\">c3k</span></span></span>"`;
|
||||||
|
|
||||||
exports[`metadata loads categories and values from dataset appear 1`] = `"<div style=\\"display: flex; justify-content: space-between; align-items: baseline;\\"><div style=\\"display: flex; justify-content: flex-start; align-items: flex-start;\\"><label class=\\"bp3-control bp3-checkbox\\" for=\\"category-select-louvain\\"><input id=\\"category-select-louvain\\" data-testclass=\\"category-select\\" data-testid=\\"louvain:category-select\\" type=\\"checkbox\\" checked=\\"\\"><span class=\\"bp3-control-indicator\\"></span></label><span role=\\"menuitem\\" tabindex=\\"0\\" data-testclass=\\"category-expand\\" data-testid=\\"louvain:category-expand\\" style=\\"cursor: pointer;\\"><span class=\\"bp3-popover-wrapper\\"><span class=\\"bp3-popover-target\\"><span data-testid=\\"louvain:category-label\\" aria-label=\\"louvain\\" class=\\"\\" tabindex=\\"0\\" style=\\"max-width: 265px;\\"><span style=\\"max-width: 265px; display: flex; overflow: hidden; justify-content: flex-start;\\"><span style=\\"overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex-shrink: 1; min-width: 5px;\\">lou</span><span style=\\"position: relative; overflow: hidden; white-space: nowrap;\\"><span style=\\"color: transparent;\\">vain</span><span style=\\"position: absolute; right: 0px; color: inherit;\\">vain</span></span></span></span></span></span><svg stroke=\\"currentColor\\" fill=\\"currentColor\\" stroke-width=\\"0\\" viewBox=\\"0 0 320 512\\" data-testclass=\\"category-expand-is-not-expanded\\" height=\\"1em\\" width=\\"1em\\" xmlns=\\"http://www.w3.org/2000/svg\\" style=\\"font-size: 10px; margin-left: 5px;\\"><path d=\\"M285.476 272.971L91.132 467.314c-9.373 9.373-24.569 9.373-33.941 0l-22.667-22.667c-9.357-9.357-9.375-24.522-.04-33.901L188.505 256 34.484 101.255c-9.335-9.379-9.317-24.544.04-33.901l22.667-22.667c9.373-9.373 24.569-9.373 33.941 0L285.475 239.03c9.373 9.372 9.373 24.568.001 33.941z\\"></path></svg></span></div><div><span class=\\"bp3-popover-wrapper\\"><span class=\\"bp3-popover-target\\"><a role=\\"button\\" data-testclass=\\"colorby\\" data-testid=\\"colorby-louvain\\" class=\\"bp3-button\\" tabindex=\\"0\\"><span icon=\\"tint\\" class=\\"bp3-icon bp3-icon-tint\\"><svg data-icon=\\"tint\\" width=\\"16\\" height=\\"16\\" viewBox=\\"0 0 16 16\\"><desc>tint</desc><path d=\\"M7.88 1s-4.9 6.28-4.9 8.9c.01 2.82 2.34 5.1 4.99 5.1 2.65-.01 5.03-2.3 5.03-5.13C12.99 7.17 7.88 1 7.88 1z\\" fill-rule=\\"evenodd\\"></path></svg></span></a></span></span></div></div><div style=\\"margin-left: 26px;\\"></div><div></div>"`;
|
exports[`metadata loads categories and values from dataset appear 1`] = `"<div style=\\"display: flex; justify-content: space-between; align-items: baseline;\\"><div style=\\"display: flex; justify-content: flex-start; align-items: flex-start;\\"><label class=\\"bp3-control bp3-checkbox\\" for=\\"category-select-louvain\\"><input id=\\"category-select-louvain\\" data-testclass=\\"category-select\\" data-testid=\\"louvain:category-select\\" type=\\"checkbox\\" checked=\\"\\"><span class=\\"bp3-control-indicator\\"></span></label><span role=\\"menuitem\\" tabindex=\\"0\\" data-testclass=\\"category-expand\\" data-testid=\\"louvain:category-expand\\" style=\\"cursor: pointer;\\"><span class=\\"bp3-popover-wrapper\\"><span class=\\"bp3-popover-target\\"><span data-testid=\\"louvain:category-label\\" aria-label=\\"louvain\\" class=\\"\\" tabindex=\\"0\\" style=\\"max-width: 265px;\\"><span style=\\"max-width: 265px; display: flex; overflow: hidden; justify-content: flex-start;\\"><span style=\\"overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex-shrink: 1; min-width: 5px;\\">lou</span><span style=\\"position: relative; overflow: hidden; white-space: nowrap;\\"><span style=\\"color: transparent;\\">vain</span><span style=\\"position: absolute; right: 0px; color: inherit;\\">vain</span></span></span></span></span></span><svg stroke=\\"currentColor\\" fill=\\"currentColor\\" stroke-width=\\"0\\" viewBox=\\"0 0 320 512\\" data-testclass=\\"category-expand-is-not-expanded\\" height=\\"1em\\" width=\\"1em\\" xmlns=\\"http://www.w3.org/2000/svg\\" style=\\"font-size: 10px; margin-left: 5px;\\"><path d=\\"M285.476 272.971L91.132 467.314c-9.373 9.373-24.569 9.373-33.941 0l-22.667-22.667c-9.357-9.357-9.375-24.522-.04-33.901L188.505 256 34.484 101.255c-9.335-9.379-9.317-24.544.04-33.901l22.667-22.667c9.373-9.373 24.569-9.373 33.941 0L285.475 239.03c9.373 9.372 9.373 24.568.001 33.941z\\"></path></svg></span></div><div><span class=\\"bp3-popover-wrapper\\"><span class=\\"bp3-popover-target\\"><a role=\\"button\\" data-testclass=\\"colorby\\" data-testid=\\"colorby-louvain\\" class=\\"bp3-button\\" tabindex=\\"0\\"><span icon=\\"tint\\" class=\\"bp3-icon bp3-icon-tint\\"><svg data-icon=\\"tint\\" width=\\"16\\" height=\\"16\\" viewBox=\\"0 0 16 16\\"><desc>tint</desc><path d=\\"M7.88 1s-4.9 6.28-4.9 8.9c.01 2.82 2.34 5.1 4.99 5.1 2.65-.01 5.03-2.3 5.03-5.13C12.99 7.17 7.88 1 7.88 1z\\" fill-rule=\\"evenodd\\"></path></svg></span></a></span></span></div></div><div style=\\"margin-left: 26px;\\"></div><div></div>"`;
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
|
"react/jsx-no-target-blank": "off",
|
||||||
"eslint-comments/require-description": ["error"],
|
"eslint-comments/require-description": ["error"],
|
||||||
"no-magic-numbers": "off",
|
"no-magic-numbers": "off",
|
||||||
"no-nested-ternary": "off",
|
"no-nested-ternary": "off",
|
||||||
|
|||||||
@@ -5,14 +5,14 @@ action creators related to embeddings choice
|
|||||||
import { AnnoMatrixObsCrossfilter } from "../annoMatrix";
|
import { AnnoMatrixObsCrossfilter } from "../annoMatrix";
|
||||||
import { _setEmbeddingSubset } from "../util/stateManager/viewStackHelpers";
|
import { _setEmbeddingSubset } from "../util/stateManager/viewStackHelpers";
|
||||||
|
|
||||||
export async function _switchEmbedding(prevAnnoMatrix, newEmbeddingName) {
|
export async function _switchEmbedding(prevAnnoMatrix, prevCrossfilter, newEmbeddingName) {
|
||||||
/*
|
/*
|
||||||
DRY helper used by this and reembedding action creators
|
DRY helper used by this and reembedding action creators
|
||||||
*/
|
*/
|
||||||
const base = prevAnnoMatrix.base();
|
const base = prevAnnoMatrix.base();
|
||||||
const embeddingDf = await base.fetch("emb", newEmbeddingName);
|
const embeddingDf = await base.fetch("emb", newEmbeddingName);
|
||||||
const annoMatrix = _setEmbeddingSubset(prevAnnoMatrix, embeddingDf);
|
const annoMatrix = _setEmbeddingSubset(prevAnnoMatrix, embeddingDf);
|
||||||
const obsCrossfilter = await new AnnoMatrixObsCrossfilter(annoMatrix).select(
|
const obsCrossfilter = await new AnnoMatrixObsCrossfilter(annoMatrix, prevCrossfilter.obsCrossfilter).select(
|
||||||
"emb",
|
"emb",
|
||||||
newEmbeddingName,
|
newEmbeddingName,
|
||||||
{
|
{
|
||||||
@@ -30,9 +30,10 @@ export const layoutChoiceAction = (newLayoutChoice) => async (
|
|||||||
On layout choice, make sure we have selected all on the previous layout, AND the new
|
On layout choice, make sure we have selected all on the previous layout, AND the new
|
||||||
layout.
|
layout.
|
||||||
*/
|
*/
|
||||||
const { annoMatrix: prevAnnoMatrix } = getState();
|
const { annoMatrix: prevAnnoMatrix, obsCrossfilter: prevCrossfilter } = getState();
|
||||||
const [annoMatrix, obsCrossfilter] = await _switchEmbedding(
|
const [annoMatrix, obsCrossfilter] = await _switchEmbedding(
|
||||||
prevAnnoMatrix,
|
prevAnnoMatrix,
|
||||||
|
prevCrossfilter,
|
||||||
newLayoutChoice
|
newLayoutChoice
|
||||||
);
|
);
|
||||||
dispatch({
|
dispatch({
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ async function configFetch(dispatch) {
|
|||||||
|
|
||||||
async function userInfoFetch(dispatch) {
|
async function userInfoFetch(dispatch) {
|
||||||
return fetchJson("userinfo").then((response) => {
|
return fetchJson("userinfo").then((response) => {
|
||||||
const userinfo = { ...response.userinfo };
|
const { userinfo } = response || {};
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "userinfo load complete",
|
type: "userinfo load complete",
|
||||||
userinfo,
|
userinfo,
|
||||||
|
|||||||
@@ -491,19 +491,7 @@ const CategoryRender = React.memo(
|
|||||||
/*
|
/*
|
||||||
Entire category has a single value, special case.
|
Entire category has a single value, special case.
|
||||||
*/
|
*/
|
||||||
const theOneValue = categorySummary.categoryValues[0];
|
return null;
|
||||||
return (
|
|
||||||
<div style={{ marginBottom: 10, marginTop: 4 }}>
|
|
||||||
<Truncate>
|
|
||||||
<span style={{ maxWidth: 150, fontWeight: 700 }}>
|
|
||||||
{metadataField}
|
|
||||||
</span>
|
|
||||||
</Truncate>
|
|
||||||
<Truncate>
|
|
||||||
<span style={{ maxWidth: 150 }}>{`: ${theOneValue}`}</span>
|
|
||||||
</Truncate>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ class CategoryValue extends React.Component {
|
|||||||
get shouldRenderStackedBarOrHistogram() {
|
get shouldRenderStackedBarOrHistogram() {
|
||||||
const { colorAccessor, isColorBy, annotations } = this.props;
|
const { colorAccessor, isColorBy, annotations } = this.props;
|
||||||
|
|
||||||
return colorAccessor && !isColorBy && !annotations.isEditingLabelName;
|
return !!colorAccessor && !isColorBy && !annotations.isEditingLabelName;
|
||||||
}
|
}
|
||||||
|
|
||||||
handleDeleteValue = () => {
|
handleDeleteValue = () => {
|
||||||
@@ -439,7 +439,9 @@ class CategoryValue extends React.Component {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
!this.shouldRenderStackedBarOrHistogram ||
|
!this.shouldRenderStackedBarOrHistogram ||
|
||||||
!AnnotationsHelpers.isContinuousAnnotation(schema, colorAccessor)
|
// This function returns true on categorical annotations(when stacked bar should not render),
|
||||||
|
// in cases where the colorAccessor is a gene this function will return undefined since genes do not live on the schema
|
||||||
|
AnnotationsHelpers.isCategoricalAnnotation(schema, colorAccessor) === true
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import React, { PureComponent } from "react";
|
||||||
|
import { connect, shallowEqual } from "react-redux";
|
||||||
|
import { Drawer } from "@blueprintjs/core";
|
||||||
|
import Async from "react-async";
|
||||||
|
|
||||||
|
import InfoFormat from "./infoFormat";
|
||||||
|
import {
|
||||||
|
selectableCategoryNames,
|
||||||
|
createCategorySummaryFromDfCol,
|
||||||
|
} from "../../util/stateManager/controlsHelpers";
|
||||||
|
|
||||||
|
@connect((state) => {
|
||||||
|
return {
|
||||||
|
annoMatrix: state.annoMatrix,
|
||||||
|
schema: state.annoMatrix.schema,
|
||||||
|
datasetTitle: state.config?.displayNames?.dataset ?? "",
|
||||||
|
aboutURL: state.config?.links?.["about-dataset"],
|
||||||
|
isOpen: state.controls.datasetDrawer,
|
||||||
|
dataPortalProps: state.config?.["corpora_props"] ?? {},
|
||||||
|
};
|
||||||
|
})
|
||||||
|
class InfoDrawer extends PureComponent {
|
||||||
|
static watchAsync(props, prevProps) {
|
||||||
|
return !shallowEqual(props.watchProps, prevProps.watchProps);
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchAsyncProps = async (props) => {
|
||||||
|
const { schema } = props.watchProps;
|
||||||
|
const { annoMatrix } = this.props;
|
||||||
|
|
||||||
|
const allCategoryNames = selectableCategoryNames(schema).sort();
|
||||||
|
|
||||||
|
const nonUserAnnoCategories = allCategoryNames.map((catName) => {
|
||||||
|
const isUserAnno = schema?.annotations?.obsByName[catName]?.writable;
|
||||||
|
if (!isUserAnno) return annoMatrix.fetch("obs", catName);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
const singleValueCategories = (
|
||||||
|
await Promise.all(nonUserAnnoCategories)
|
||||||
|
).reduce((acc, categoryData, i) => {
|
||||||
|
const catName = allCategoryNames[i];
|
||||||
|
|
||||||
|
const column = categoryData.icol(0);
|
||||||
|
const colSchema = schema.annotations.obsByName[catName];
|
||||||
|
|
||||||
|
const categorySummary = createCategorySummaryFromDfCol(column, colSchema);
|
||||||
|
|
||||||
|
const { numCategoryValues } = categorySummary;
|
||||||
|
// Add to the array if the category has only one value
|
||||||
|
if (numCategoryValues === 1) {
|
||||||
|
acc.set(catName, categorySummary.allCategoryValues[0]);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, new Map());
|
||||||
|
|
||||||
|
return { singleValueCategories };
|
||||||
|
};
|
||||||
|
|
||||||
|
handleClose = () => {
|
||||||
|
const { dispatch } = this.props;
|
||||||
|
|
||||||
|
dispatch({ type: "toggle dataset drawer" });
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
position,
|
||||||
|
aboutURL,
|
||||||
|
datasetTitle,
|
||||||
|
schema,
|
||||||
|
isOpen,
|
||||||
|
dataPortalProps,
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
title="Dataset Overview"
|
||||||
|
onClose={this.handleClose}
|
||||||
|
{...{ isOpen, position }}
|
||||||
|
>
|
||||||
|
<Async
|
||||||
|
watchFn={InfoDrawer.watchAsync}
|
||||||
|
promiseFn={this.fetchAsyncProps}
|
||||||
|
watchProps={{ schema }}
|
||||||
|
>
|
||||||
|
<Async.Pending>
|
||||||
|
<InfoFormat
|
||||||
|
skeleton
|
||||||
|
{...{ datasetTitle, aboutURL, dataPortalProps }}
|
||||||
|
/>
|
||||||
|
</Async.Pending>
|
||||||
|
<Async.Rejected>
|
||||||
|
{(error) => {
|
||||||
|
console.error(error);
|
||||||
|
return <span>Failed to load info</span>;
|
||||||
|
}}
|
||||||
|
</Async.Rejected>
|
||||||
|
<Async.Fulfilled>
|
||||||
|
{(asyncProps) => {
|
||||||
|
const { singleValueCategories } = asyncProps;
|
||||||
|
return (
|
||||||
|
<InfoFormat
|
||||||
|
{...{
|
||||||
|
datasetTitle,
|
||||||
|
aboutURL,
|
||||||
|
singleValueCategories,
|
||||||
|
dataPortalProps,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Async.Fulfilled>
|
||||||
|
</Async>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export default InfoDrawer;
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { H3, H1, UL, Classes } from "@blueprintjs/core";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const renderContributors = (contributors, affiliations, skeleton) => {
|
||||||
|
// eslint-disable-next-line no-constant-condition -- Temp removed contributor section to avoid publishing PII
|
||||||
|
if (!contributors || contributors.length === 0 || true) return null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<H3 className={skeleton ? Classes.SKELETON : null}>Contributors</H3>
|
||||||
|
<p className={skeleton ? Classes.SKELETON : null}>
|
||||||
|
{contributors.map((contributor) => {
|
||||||
|
const { email, name, institution } = contributor;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span key={name}>
|
||||||
|
{name}
|
||||||
|
{email && `(${email})`}
|
||||||
|
<sup>{affiliations.indexOf(institution) + 1}</sup>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
{renderAffiliations(affiliations, skeleton)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// generates a list of unique institutions by order of appearance in contributors
|
||||||
|
const buildAffiliations = (contributors = []) => {
|
||||||
|
const affiliations = [];
|
||||||
|
contributors.forEach((contributor) => {
|
||||||
|
const { institution } = contributor;
|
||||||
|
if (affiliations.indexOf(institution) === -1) {
|
||||||
|
affiliations.push(institution);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return affiliations;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderAffiliations = (affiliations, skeleton) => {
|
||||||
|
if (affiliations.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<H3 className={skeleton ? Classes.SKELETON : null}>Affiliations</H3>
|
||||||
|
<UL>
|
||||||
|
{affiliations.map((item, index) => (
|
||||||
|
<div key={item} className={skeleton ? Classes.SKELETON : null}>
|
||||||
|
<sup>{index + 1}</sup>
|
||||||
|
{" "}
|
||||||
|
{item}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</UL>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderDOILink = (type, doi, skeleton) => {
|
||||||
|
if (!doi) return null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<H3 className={skeleton ? Classes.SKELETON : null}>{type}</H3>
|
||||||
|
<p className={skeleton ? Classes.SKELETON : null}>
|
||||||
|
<a href={doi} target="_blank" rel="noopener">
|
||||||
|
{doi}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderOrganism = (organism, skeleton) => {
|
||||||
|
if (!organism) return null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<H3 className={skeleton ? Classes.SKELETON : null}>Organism</H3>
|
||||||
|
<p className={skeleton ? Classes.SKELETON : null}>{organism}</p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render list of metadata attributes found in categorical field
|
||||||
|
// Ignores categories with empty or null values
|
||||||
|
const renderSingleValueCategories = (singleValueCategories, skeleton) => {
|
||||||
|
if (singleValueCategories.size === 0) return null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<H3 className={skeleton ? Classes.SKELETON : null}>Dataset Metadata</H3>
|
||||||
|
<UL>
|
||||||
|
{Array.from(singleValueCategories).map((pair) => {
|
||||||
|
if (!pair[1] || pair[1] === "") return null;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
className={skeleton ? Classes.SKELETON : null}
|
||||||
|
key={pair[0]}
|
||||||
|
>{`${pair[0]}: ${pair[1]}`}</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</UL>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Renders any links found in the config where link_type is not "SUMMARY"
|
||||||
|
// If there are no links in the config, render the aboutURL
|
||||||
|
const renderLinks = (projectLinks, aboutURL, skeleton) => {
|
||||||
|
if (!projectLinks && !aboutURL) return null;
|
||||||
|
if (projectLinks)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<H3 className={skeleton ? Classes.SKELETON : null}>Project Links</H3>
|
||||||
|
<UL>
|
||||||
|
{projectLinks.map((link) => {
|
||||||
|
if (link.link_type === "SUMMARY") return null;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={link.link_name}
|
||||||
|
className={skeleton ? Classes.SKELETON : null}
|
||||||
|
>
|
||||||
|
<a href={link.link_url} target="_blank" rel="noopener">
|
||||||
|
{link.link_name}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</UL>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<H3 className={skeleton ? Classes.SKELETON : null}>More Info</H3>
|
||||||
|
<p>
|
||||||
|
<a
|
||||||
|
className={skeleton ? Classes.SKELETON : null}
|
||||||
|
href={aboutURL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>
|
||||||
|
{aboutURL}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const NUM_CATEGORIES = 8;
|
||||||
|
|
||||||
|
// Generates arbitrary placeholder array for singleValueCategories skeleton shape
|
||||||
|
const singleValueCategoriesPlaceholder = Array.from(Array(NUM_CATEGORIES)).map(
|
||||||
|
(_, index) => {
|
||||||
|
return [index, index];
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const InfoFormat = React.memo(
|
||||||
|
({
|
||||||
|
datasetTitle,
|
||||||
|
singleValueCategories = new Map(singleValueCategoriesPlaceholder),
|
||||||
|
aboutURL = "thisisabouthtelengthofaurl",
|
||||||
|
dataPortalProps = {},
|
||||||
|
skeleton = false,
|
||||||
|
}) => {
|
||||||
|
if (dataPortalProps.corpora_schema_version === "1.0.0") {
|
||||||
|
dataPortalProps = {};
|
||||||
|
}
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
publication_doi: doi,
|
||||||
|
preprint_doi: preprintDOI,
|
||||||
|
organism,
|
||||||
|
contributors,
|
||||||
|
project_links: projectLinks,
|
||||||
|
} = dataPortalProps;
|
||||||
|
|
||||||
|
const affiliations = buildAffiliations(contributors);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ margin: 24, overflow: "auto" }}>
|
||||||
|
<H1 className={skeleton ? Classes.SKELETON : null}>
|
||||||
|
{title ?? datasetTitle}
|
||||||
|
</H1>
|
||||||
|
{renderContributors(contributors, affiliations, skeleton)}
|
||||||
|
{renderDOILink("DOI", doi, skeleton)}
|
||||||
|
{renderDOILink("Preprint DOI", preprintDOI, skeleton)}
|
||||||
|
{renderOrganism(organism, skeleton)}
|
||||||
|
{renderSingleValueCategories(singleValueCategories, skeleton)}
|
||||||
|
{renderLinks(projectLinks, aboutURL, skeleton)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default InfoFormat;
|
||||||
@@ -1,22 +1,27 @@
|
|||||||
// jshint esversion: 6
|
// jshint esversion: 6
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
|
import { Button } from "@blueprintjs/core";
|
||||||
|
import { IconNames } from "@blueprintjs/icons";
|
||||||
|
|
||||||
import * as globals from "../../globals";
|
import * as globals from "../../globals";
|
||||||
import Logo from "../framework/logo";
|
import Logo from "../framework/logo";
|
||||||
import Truncate from "../util/truncate";
|
import Truncate from "../util/truncate";
|
||||||
|
import InfoDrawer from "../infoDrawer/infoDrawer";
|
||||||
|
|
||||||
const DATASET_TITLE_WIDTH = 190;
|
|
||||||
const DATASET_TITLE_FONT_SIZE = 14;
|
const DATASET_TITLE_FONT_SIZE = 14;
|
||||||
|
|
||||||
@connect((state) => ({
|
@connect((state) => ({
|
||||||
datasetTitle: state.config?.displayNames?.dataset ?? "",
|
datasetTitle: state.config?.displayNames?.dataset ?? "",
|
||||||
aboutURL: state.config?.links?.["about-dataset"],
|
|
||||||
scatterplotXXaccessor: state.controls.scatterplotXXaccessor,
|
|
||||||
scatterplotYYaccessor: state.controls.scatterplotYYaccessor,
|
|
||||||
}))
|
}))
|
||||||
class LeftSideBar extends React.Component {
|
class LeftSideBar extends React.Component {
|
||||||
|
handleClick = () => {
|
||||||
|
const { dispatch } = this.props;
|
||||||
|
dispatch({ type: "toggle dataset drawer" });
|
||||||
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { datasetTitle, aboutURL } = this.props;
|
const { datasetTitle } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -53,39 +58,23 @@ class LeftSideBar extends React.Component {
|
|||||||
</span>
|
</span>
|
||||||
gene
|
gene
|
||||||
</span>
|
</span>
|
||||||
<div
|
<Button
|
||||||
|
minimal
|
||||||
|
icon={IconNames.BOOK}
|
||||||
style={{
|
style={{
|
||||||
fontSize: DATASET_TITLE_FONT_SIZE,
|
fontSize: DATASET_TITLE_FONT_SIZE,
|
||||||
position: "relative",
|
position: "absolute",
|
||||||
top: -6,
|
right: 10,
|
||||||
display: "inline-block",
|
|
||||||
width: DATASET_TITLE_WIDTH,
|
|
||||||
marginLeft: "7px",
|
|
||||||
height: "1.2em",
|
|
||||||
overflow: "hidden",
|
|
||||||
wordBreak: "break-all",
|
|
||||||
}}
|
}}
|
||||||
|
onClick={this.handleClick}
|
||||||
>
|
>
|
||||||
{aboutURL ? (
|
<Truncate>
|
||||||
<Truncate>
|
<span style={{ maxWidth: 155 }} data-testid="header">
|
||||||
<a
|
{datasetTitle}
|
||||||
style={{ width: 185 }}
|
</span>
|
||||||
href={aboutURL}
|
</Truncate>
|
||||||
data-testid="header"
|
</Button>
|
||||||
target="_blank"
|
<InfoDrawer />
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
{datasetTitle}
|
|
||||||
</a>
|
|
||||||
</Truncate>
|
|
||||||
) : (
|
|
||||||
<Truncate>
|
|
||||||
<span style={{ width: 185 }} data-testid="header">
|
|
||||||
{datasetTitle}
|
|
||||||
</span>
|
|
||||||
</Truncate>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -250,10 +250,7 @@ class MenuBar extends React.PureComponent {
|
|||||||
>
|
>
|
||||||
<AuthButtons auth={auth} userinfo={userinfo} />
|
<AuthButtons auth={auth} userinfo={userinfo} />
|
||||||
<InformationMenu
|
<InformationMenu
|
||||||
libraryVersions={libraryVersions}
|
{...{ libraryVersions, aboutLink, tosURL, privacyURL, dispatch }}
|
||||||
aboutLink={aboutLink}
|
|
||||||
tosURL={tosURL}
|
|
||||||
privacyURL={privacyURL}
|
|
||||||
/>
|
/>
|
||||||
<UndoRedoReset
|
<UndoRedoReset
|
||||||
dispatch={dispatch}
|
dispatch={dispatch}
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
// jshint esversion: 6
|
// jshint esversion: 6
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Button, Popover, Menu, MenuItem, Position } from "@blueprintjs/core";
|
import { Button, Popover, Menu, MenuItem, Position } from "@blueprintjs/core";
|
||||||
|
import { IconNames } from "@blueprintjs/icons";
|
||||||
import styles from "./menubar.css";
|
import styles from "./menubar.css";
|
||||||
|
|
||||||
|
const handleClick = (dispatch) => {
|
||||||
|
dispatch({ type: "toggle dataset drawer" });
|
||||||
|
};
|
||||||
|
|
||||||
const InformationMenu = React.memo((props) => {
|
const InformationMenu = React.memo((props) => {
|
||||||
const { libraryVersions, aboutLink, tosURL, privacyURL } = props;
|
const { libraryVersions, tosURL, privacyURL, dispatch } = props;
|
||||||
return (
|
return (
|
||||||
<div className={`bp3-button-group ${styles.menubarButton}`}>
|
<div className={`bp3-button-group ${styles.menubarButton}`}>
|
||||||
<Popover
|
<Popover
|
||||||
content={
|
content={
|
||||||
<Menu>
|
<Menu>
|
||||||
{aboutLink ? (
|
<MenuItem
|
||||||
<MenuItem
|
onClick={() => handleClick(dispatch)}
|
||||||
href={aboutLink}
|
icon={IconNames.BOOK}
|
||||||
target="_blank"
|
text="Dataset Overview"
|
||||||
icon="document-open"
|
/>
|
||||||
text="About this dataset"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
""
|
|
||||||
)}
|
|
||||||
|
|
||||||
<MenuItem
|
<MenuItem
|
||||||
href="https://chanzuckerberg.github.io/cellxgene/"
|
href="https://chanzuckerberg.github.io/cellxgene/"
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ class TermsPrompt extends React.PureComponent {
|
|||||||
}}
|
}}
|
||||||
href={tosURL}
|
href={tosURL}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener"
|
||||||
>
|
>
|
||||||
terms of service
|
terms of service
|
||||||
</a>
|
</a>
|
||||||
@@ -106,7 +106,7 @@ class TermsPrompt extends React.PureComponent {
|
|||||||
}}
|
}}
|
||||||
href={privacyURL}
|
href={privacyURL}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener"
|
||||||
>
|
>
|
||||||
privacy policy
|
privacy policy
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
Vendored
+8
@@ -20,6 +20,8 @@ const Controls = (
|
|||||||
scatterplotXXaccessor: null, // just easier to read
|
scatterplotXXaccessor: null, // just easier to read
|
||||||
scatterplotYYaccessor: null,
|
scatterplotYYaccessor: null,
|
||||||
graphRenderCounter: 0 /* integer as <Component key={graphRenderCounter} - a change in key forces a remount */,
|
graphRenderCounter: 0 /* integer as <Component key={graphRenderCounter} - a change in key forces a remount */,
|
||||||
|
|
||||||
|
datasetDrawer: false,
|
||||||
},
|
},
|
||||||
action
|
action
|
||||||
) => {
|
) => {
|
||||||
@@ -162,6 +164,12 @@ const Controls = (
|
|||||||
scatterplotYYaccessor: null,
|
scatterplotYYaccessor: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**************************
|
||||||
|
Dataset Drawer
|
||||||
|
**************************/
|
||||||
|
case "toggle dataset drawer":
|
||||||
|
return { ...state, datasetDrawer: !state.datasetDrawer };
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|||||||
+71
-61
@@ -1,4 +1,4 @@
|
|||||||
# cellxgene release process
|
# cellxgene Release Process
|
||||||
|
|
||||||
_This document defines the release process for cellxgene_
|
_This document defines the release process for cellxgene_
|
||||||
|
|
||||||
@@ -16,71 +16,65 @@ The release process should result in the following side-effects:
|
|||||||
|
|
||||||
Note all release tags pushed to GitHub MUST follow semantic versioning.
|
Note all release tags pushed to GitHub MUST follow semantic versioning.
|
||||||
|
|
||||||
## Recipe
|
## Releasing a Major or Minor Version of cellxgene
|
||||||
|
|
||||||
Follow these steps to create a release.
|
Please scroll down the section below for how to release a patch version. Follow these steps to create a major or minor release.
|
||||||
|
|
||||||
1. Preparation:
|
1. Preparation:
|
||||||
- python3.6 environment, and a cellxgene clone
|
- python3.6 environment, and a cellxgene clone
|
||||||
- Define the release version number, using [semantic versioning](https://semver.org/),
|
- Define the release version number, using [semantic versioning](https://semver.org/), and specifying all three digits (e.g., 0.3.0)
|
||||||
and specifying all three digits (eg, 0.3.0)
|
- Write the release title and release notes and add to [release notes document](https://docs.google.com/document/d/1KnHwkYfhyWO5H8BDcMu7y3ogjvq5Yi4OwpmZ8DB6w0Y/edit)
|
||||||
- Write the release title and release notes and add to
|
2. Create a release branch, eg, `release-version-0.16.0`
|
||||||
[release notes document](https://docs.google.com/document/d/1KnHwkYfhyWO5H8BDcMu7y3ogjvq5Yi4OwpmZ8DB6w0Y/edit)
|
3. In the release branch, run `make create-release-candidate PART=[major | minor | patch]` where you choose major/minor/patch depending on which part of the version is being bumped (e.g., `0.2.9` -> `0.3.0` is minor version bump). This will bump the version and create a release *candidate* version (i.e. `0.3.0-rc.0`).
|
||||||
2. Create a release branch, eg, `release-version`
|
4. Commit and push the new branch. This will trigger tests to ensure that your branch isn't broken.
|
||||||
3. In the release branch:
|
5. Upload the release candidate to Test PyPI by running the command `make release-candidate-to-test-pypi`. (Make sure you are registered for PyPI and Test PyPI and you have write access to the cellxgene PyPI package for both).
|
||||||
- Run `make release-stage-1 PART=[major | minor | patch]` where you choose major/minor/patch depending on which part of the version
|
6. Verify the release candidate in a fresh virtual environment by running `make install-release-test` which installs the cellxgene build you just uploaded the Test PyPI.
|
||||||
is being bumped (eg, 0.2.9->0.3 is minor).
|
7. If you find errors with the release candidate, run `make recreate-release-candidate` to increment the release candidate version (i.e. `0.3.0-rc.0` -> `0.3.0-rc.1`). Then go back to Steps 5 and 6 to re-upload and re-test the new release candidate.
|
||||||
4. Commit and push the new branch
|
8. If everything looks good, push the release to Test PyPI without the release candidate tag by running the command `make release-final-to-test-pypi` (i.e. `0.3.0-rc.1` -> `0.3.0`).
|
||||||
5. Create a PR for the release.
|
- **NOTE:** Once you push the final release version to Test PyPI, you cannot ever re-upload the build again. If you need to make changes to the build, you will have to "burn" the version number and bump the part again and go back to step 1 with a brand new version number. For example, if you upload `0.3.0` to Test PyPI and realize there's a bug, you will have to create a new version `0.4.0` and there will be no `0.3.0` version of cellxgene. This is why testing the release candidate is very important.
|
||||||
- [optional] As needed, conduct PR review.
|
9. Create a PR for the release and conduct a PR review.
|
||||||
6. Merge to the `main` branch
|
10. Merge to the `main` branch.
|
||||||
7. Publish to pypi by performing the following steps (assumes you that you have registered for pypi,
|
11. Publish to PyPI (prod) (assuming you that you have registered for PyPI, and that you have write access to the cellxgene pypi package) by running `make release-final`.
|
||||||
and that you have write access to the cellxgene pypi package):
|
12. Test the installation in a fresh virtual environment by running `pip install --no-cache-dir cellxgene`.
|
||||||
- Build the distribution and upload to test pypi `make release-stage-2`
|
13. Create Github release using the version number and release notes ([instructions](https://help.github.com/articles/creating-releases/)):
|
||||||
- Test the test installation in a fresh virtual environment using `make install-release-test`
|
- Draft new release
|
||||||
- Upload the package to real pypi using `make release-stage-final`
|
- Type version name matching release version number from (1)
|
||||||
- Test the installation in a fresh virtual environment using `pip install cellxgene`
|
- Select `main` as release branch (ensure you merged the release PR)
|
||||||
8. Create Github release using the version number and release notes
|
- Type title `Release {version num}`
|
||||||
([instructions](https://help.github.com/articles/creating-releases/)).
|
- [optional] Check pre-release if this release is not ready for production
|
||||||
- Draft new release
|
- Publish Release
|
||||||
- Type version name matching release version number from (1)
|
|
||||||
- Select `main` as release branch (ensure you merged the release PR)
|
|
||||||
- Type title `Release {version num}`
|
|
||||||
- [optional] Check pre-release if this release is not ready for production
|
|
||||||
- Publish Release
|
|
||||||
|
|
||||||
The optional steps are for testing purposes, and are recommended
|
The optional steps are for testing purposes, and are recommended for publishing any major releases, and any releases that significantly change the packaging (e.g. new bundled files, new dependencies, etc.)
|
||||||
for publishing any major releases, and any releases that significantly
|
|
||||||
change the packaging (e.g. new bundled files, new dependencies, etc.)
|
|
||||||
|
|
||||||
### Point release (special case)
|
### Releasing a Patch Version of cellxgene (special case)
|
||||||
|
|
||||||
To make a bugfix release (a point release) when there are already other changes in `main` we need to do a modified version of our release process. The difference is that instead of using `main` we are going make our release branch off of the tag for the release we want to patch. We cherrypick the commits that we want to include in the patch. Then instead of merging to `main`, we create the release directly off of the branch.
|
To make a bugfix release (a point release/patch release) when there are already other changes in `main` we need to do a modified version of our release process. The difference is that instead of using `main` we are going make our release branch off of the tag for the release we want to patch. We cherrypick the commits that we want to include in the patch. Then instead of merging to `main`, we create the release directly off of the branch.
|
||||||
|
|
||||||
1. (same as above) Preparation:
|
1. (same as above) Preparation:
|
||||||
- python3.6 environment, and a cellxgene clone
|
- python3.6 environment, and a cellxgene clone
|
||||||
- Define the release version number, using [semantic versioning](https://semver.org/),
|
- Define the release version number, using [semantic versioning](https://semver.org/), and specifying all three digits (e.g., 0.3.2) (for this you will update the last digit to represent a bugfix change).
|
||||||
and specifying all three digits (eg, 0.3.0) (for this you will update the last digit to represent a bugfix change)
|
- Write the release title and release notes and add to [release notes document](https://docs.google.com/document/d/1KnHwkYfhyWO5H8BDcMu7y3ogjvq5Yi4OwpmZ8DB6w0Y/edit)
|
||||||
- Write the release title and release notes and add to
|
|
||||||
[release notes document](https://docs.google.com/document/d/1KnHwkYfhyWO5H8BDcMu7y3ogjvq5Yi4OwpmZ8DB6w0Y/edit)
|
|
||||||
2. Create a release branch off of the tag for the release you want to update.
|
2. Create a release branch off of the tag for the release you want to update.
|
||||||
- Checkout the tag for the release you want to fix. ex. if we are fixing 0.9.0: `git checkout 0.9.0`
|
- Checkout the tag for the release you want to fix. For example, if we are fixing 0.9.0: `git checkout 0.9.0`.
|
||||||
- Create a branch from that tag. `git branch release-0.9.1`
|
- Create a branch from that tag. `git branch release-version-0.9.1`
|
||||||
3. Cherrypick the commits that you want included in this patch.
|
3. Cherrypick the commits that you want included in this patch.
|
||||||
- Test that the cherrypicked commits landed and fixed the issue
|
- Test that the cherrypicked commits landed and fixed the issue locally.
|
||||||
- We WILL NOT merge this branch back into `main`, these commits should already exist in `main`.
|
- We **WILL NOT** merge this branch back into `main` as these commits should already exist in `main`.
|
||||||
4. In the release branch:
|
4. In the release branch (i.e. `release-version-0.9.1`), run `make create-release-candidate PART=patch` to bump the patch version and create the first release candidate (i.e. `0.9.1-rc.0`).
|
||||||
- Run `make release-stage-1 PART=patch`.
|
5. Run `make release-candidate-to-test-pypi` to upload the release candidate to Test PyPI.
|
||||||
5. Commit and push the new branch. DO NOT MAKE A PR OR MERGE TO `main`.
|
6. Verify the release candidate in a fresh virtual environment by running `make install-release-test` which installs the cellxgene build you just uploaded the Test PyPI.
|
||||||
- wait for release to pass the tests
|
7. If you find errors with the release candidate, run `make recreate-release-candidate` to increment the release candidate version (i.e. `0.9.1-rc.0` -> `0.9.1-rc.1`). Then go back to Steps 5 and 6 to re-upload and re-test the new release candidate.
|
||||||
6. Publish to pypi by performing the following steps (assumes you that you have registered for pypi,
|
8. If everything looks good, push the final version of the release to Test PyPI without the release candidate tag by running the command `make release-final-to-test-pypi` (i.e. `0.9.1-rc.1` -> `0.9.1`).
|
||||||
and that you have write access to the cellxgene pypi package): - Build the distribution and upload to test pypi `make release-stage-2` - Test the test installation in a fresh virtual environment using `make install-release-test` - Upload the package to real pypi using `make release-stage-final` - Test the installation in a fresh virtual environment using
|
- **NOTE:** Once you push the final release version to Test PyPI, you cannot ever re-upload the build again. If you need to make changes to the build, you will have to "burn" the version number and bump the part again and go back to step 1 with a brand new version number. For example, if you upload `0.9.1` to Test PyPI and realize there's a bug, you will have to create a new version `0.9.2` and there will be no `0.9.1` version of cellxgene. This is why testing the release candidate is very important.
|
||||||
`pip install --no-cache-dir cellxgene`
|
9. Commit and push the new branch. DO NOT MAKE A PR OR MERGE TO `main`.
|
||||||
7. Create Github release using the version number and release notes
|
- Wait for release to pass the tests.
|
||||||
|
10. Publish to PyPI (prod) (assuming you that you have registered for PyPI, and that you have write access to the cellxgene pypi package) by running `make release-final`.
|
||||||
|
11. Test the installation in a fresh virtual environment by running `pip install --no-cache-dir cellxgene`.
|
||||||
|
12. Create Github release using the version number and release notes
|
||||||
([instructions](https://help.github.com/articles/creating-releases/)).
|
([instructions](https://help.github.com/articles/creating-releases/)).
|
||||||
- Draft new release
|
- Draft new release
|
||||||
- Type version name matching release version number from (1)
|
- Type version name matching release version number from (1)
|
||||||
- _Different than above_ Select the release-branch you pushed at step 5 as release branch
|
- [**_Different than above_**] Select the release-branch you pushed at step 5 as release branch
|
||||||
- Type title `Release {version num}`
|
- Type title `Release {version num}`
|
||||||
- [optional] Check pre-release if this release is not ready for production
|
- [optional] Check pre-release if this release is not ready for production
|
||||||
- Publish Release
|
- Publish Release
|
||||||
@@ -93,36 +87,52 @@ _PyPi doesn't allow you to reupload a release with the same version number_
|
|||||||
If you accidentally burned a release number you want to use on prod, you have a few options:
|
If you accidentally burned a release number you want to use on prod, you have a few options:
|
||||||
|
|
||||||
1. OPTION 1: Create distribution `make pydist`; test release locally `pip install dist/<release tarball>`;
|
1. OPTION 1: Create distribution `make pydist`; test release locally `pip install dist/<release tarball>`;
|
||||||
then upload to prod `make release-stage-final`.
|
then upload to prod `make release-final`.
|
||||||
2. OPTION 2: (DANGER) release directly to prod: `make release-directly-to-prod`.
|
2. OPTION 2: (DANGER) release directly to prod: `make release-directly-to-prod`.
|
||||||
3. OPTION 3: If the release was burned on prod as well run from Step 3 again with option
|
3. OPTION 3: If the release was burned on prod as well run from Step 3 again with option PART=patch until you get to an unburned version.
|
||||||
PART=patch until you get to an unburned version.
|
|
||||||
|
|
||||||
### The release doesn't install or fails your tests when you install it
|
### The release doesn't install or fails your tests when you install it
|
||||||
|
|
||||||
Delete it from pypi - Go to pypi.org -> sign in -> go to the cellxgene package -> click manage -> then in the options drop down click delete -> follow the instructions. You will not be able to use that release number again. If it is a minor bug and not a major regression, you can just release a patch.
|
Delete it from pypi - Go to pypi.org -> sign in -> go to the cellxgene package -> click manage -> then in the options drop down click delete -> follow the instructions. You will not be able to use that release number again. If it is a minor bug and not a major regression, you can just release a patch.
|
||||||
|
|
||||||
### If you need to run stage final on a different computer than stage 2
|
### If you need to run the final upload to PyPI (prod) on a different computer than where you ran the command to upload to Test PyPI.
|
||||||
|
|
||||||
If you run stage final without running stage 2 first, the dist will not have been build on the computer running stage final. The solution is to run `make release-directly-to-prod`. This both builds the distribution files and then releases directly to prod pypi.org.
|
If you run `make release-final` without running `make release-final-to-test-pypi` first, the dist will not have been build on the computer running the final PyPI push. The solution is to run `make release-directly-to-prod`. This both builds the distribution files and then releases directly to prod pypi.org.
|
||||||
|
|
||||||
## Stage Details
|
## Command Details
|
||||||
|
|
||||||
### Stage 1 - `make release-stage-1`
|
### Initial creation stage - `make create-release-candidate PART=[major | minor | patch]`
|
||||||
|
|
||||||
1. Pip installs requirements-dev
|
1. Pip installs requirements-dev
|
||||||
2. Bumps version by [PART]
|
2. Bumps version by [PART] and creates the first release candidate.
|
||||||
3. Deletes build directory, client/build, dist and cellxgene.egg-info
|
3. Deletes build directory, client/build, dist and cellxgene.egg-info
|
||||||
4. Creates the package-lock.json
|
4. Creates the package-lock.json
|
||||||
|
|
||||||
### Stage 2 - `make release-stage-2`
|
### Test PyPI upload stage - `make release-candidate-to-test-pypi`
|
||||||
|
|
||||||
1. Pip installs requirements-dev
|
1. Pip installs requirements-dev
|
||||||
2. Builds client and server
|
2. Builds client and server
|
||||||
3. Creates distribution release (sdist)
|
3. Creates distribution release (sdist)
|
||||||
4. Uploads to test.pypi.org
|
4. Uploads to test.pypi.org
|
||||||
|
|
||||||
### Stage final - `make release-stage-final`
|
### Recreating release candidate stage(s) - `make recreate-release-candidate`
|
||||||
|
|
||||||
|
1. Pip installs requirements-dev
|
||||||
|
2. Bumps release candidate version number.
|
||||||
|
3. Deletes build directory, client/build, dist and cellxgene.egg-info
|
||||||
|
4. Creates the package-lock.json
|
||||||
|
|
||||||
|
### Penultimate stage, final release to Test PyPI - `make release-final-to-test-pypi`
|
||||||
|
1. Pip installs requirements-dev
|
||||||
|
2. Removes release candidate tag from the version number.
|
||||||
|
3. Deletes build directory, client/build, dist and cellxgene.egg-info
|
||||||
|
4. Creates the package-lock.json
|
||||||
|
5. Pip installs requirements-dev
|
||||||
|
6. Builds client and server
|
||||||
|
7. Creates distribution release (sdist)
|
||||||
|
8. Uploads to test.pypi.org
|
||||||
|
|
||||||
|
### Final stage - `make release-final`
|
||||||
|
|
||||||
** Does not build distribution **
|
** Does not build distribution **
|
||||||
1. Uploads to pypi.org
|
1. Uploads to pypi.org
|
||||||
|
|||||||
+42
-9
@@ -2,6 +2,9 @@ import datetime
|
|||||||
import logging
|
import logging
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
|
||||||
from flask import Flask, redirect, current_app, make_response, render_template, abort, Blueprint, request, \
|
from flask import Flask, redirect, current_app, make_response, render_template, abort, Blueprint, request, \
|
||||||
send_from_directory
|
send_from_directory
|
||||||
@@ -84,10 +87,12 @@ def dataset_index(url_dataroot=None, dataset=None):
|
|||||||
cache_manager = current_app.matrix_data_cache_manager
|
cache_manager = current_app.matrix_data_cache_manager
|
||||||
with cache_manager.data_adaptor(url_dataroot, location, app_config) as data_adaptor:
|
with cache_manager.data_adaptor(url_dataroot, location, app_config) as data_adaptor:
|
||||||
data_adaptor.set_uri_path(f"{url_dataroot}/{dataset}")
|
data_adaptor.set_uri_path(f"{url_dataroot}/{dataset}")
|
||||||
dataset_title = app_config.get_title(data_adaptor)
|
args = {
|
||||||
return render_template(
|
"SCRIPTS" : scripts,
|
||||||
"index.html", datasetTitle=dataset_title, SCRIPTS=scripts, INLINE_SCRIPTS=inline_scripts
|
"INLINE_SCRIPTS" : inline_scripts
|
||||||
)
|
}
|
||||||
|
return render_template("index.html", **args)
|
||||||
|
|
||||||
except DatasetAccessError as e:
|
except DatasetAccessError as e:
|
||||||
return common_rest.abort_and_log(
|
return common_rest.abort_and_log(
|
||||||
e.status_code, f"Invalid dataset {dataset}: {e.message}", loglevel=logging.INFO, include_exc_info=True
|
e.status_code, f"Invalid dataset {dataset}: {e.message}", loglevel=logging.INFO, include_exc_info=True
|
||||||
@@ -179,9 +184,9 @@ def dataroot_test_index():
|
|||||||
data += f"<p>Logged in as {auth.get_user_id()} / {auth.get_user_name()} / {auth.get_user_email()}</p>"
|
data += f"<p>Logged in as {auth.get_user_id()} / {auth.get_user_name()} / {auth.get_user_email()}</p>"
|
||||||
if auth.requires_client_login():
|
if auth.requires_client_login():
|
||||||
if server_config.auth.is_user_authenticated():
|
if server_config.auth.is_user_authenticated():
|
||||||
data += "<p><a href='/logout'>Logout</a></p>"
|
data += f"<p><a href='{auth.get_logout_url(None)}'>Logout</a></p>"
|
||||||
else:
|
else:
|
||||||
data += "<p><a href='/login'>Login</a></p>"
|
data += f"<p><a href='{auth.get_login_url(None)}'>Login</a></p>"
|
||||||
|
|
||||||
datasets = []
|
datasets = []
|
||||||
for dataroot_dict in server_config.multi_dataset__dataroot.values():
|
for dataroot_dict in server_config.multi_dataset__dataroot.values():
|
||||||
@@ -228,7 +233,8 @@ class DatasetResource(Resource):
|
|||||||
|
|
||||||
|
|
||||||
class SchemaAPI(DatasetResource):
|
class SchemaAPI(DatasetResource):
|
||||||
@cache_control(public=True, max_age=ONE_WEEK)
|
# TODO @mdunitz separate dataset schema and user schema
|
||||||
|
@cache_control(no_store=True)
|
||||||
@rest_get_data_adaptor
|
@rest_get_data_adaptor
|
||||||
def get(self, data_adaptor):
|
def get(self, data_adaptor):
|
||||||
return common_rest.schema_get(data_adaptor)
|
return common_rest.schema_get(data_adaptor)
|
||||||
@@ -329,6 +335,25 @@ def get_api_resources(bp_api, url_dataroot=None):
|
|||||||
return api
|
return api
|
||||||
|
|
||||||
|
|
||||||
|
def handle_api_base_url(app, app_config):
|
||||||
|
"""If an api_base_url is provided, then an inline script is generated to
|
||||||
|
handle the new API prefix"""
|
||||||
|
api_base_url = app_config.server_config.get_api_base_url()
|
||||||
|
if not api_base_url:
|
||||||
|
return
|
||||||
|
|
||||||
|
sha256 = hashlib.sha256(api_base_url.encode()).hexdigest()
|
||||||
|
script_name = f"api_base_url-{sha256}.js"
|
||||||
|
script_path = os.path.join(app.root_path, "../common/web/templates", script_name)
|
||||||
|
with open(script_path, "w") as fout:
|
||||||
|
fout.write("window.CELLXGENE.API.prefix = `" + api_base_url + "${location.pathname}api/`;\n")
|
||||||
|
|
||||||
|
dataset_configs = [app_config.default_dataset_config] + list(app_config.dataroot_config.values())
|
||||||
|
for dataset_config in dataset_configs:
|
||||||
|
inline_scripts = dataset_config.app__inline_scripts
|
||||||
|
inline_scripts.append(script_name)
|
||||||
|
|
||||||
|
|
||||||
class Server:
|
class Server:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _before_adding_routes(app, app_config):
|
def _before_adding_routes(app, app_config):
|
||||||
@@ -337,6 +362,7 @@ class Server:
|
|||||||
|
|
||||||
def __init__(self, app_config):
|
def __init__(self, app_config):
|
||||||
self.app = Flask(__name__, static_folder=None)
|
self.app = Flask(__name__, static_folder=None)
|
||||||
|
handle_api_base_url(self.app, app_config)
|
||||||
self._before_adding_routes(self.app, app_config)
|
self._before_adding_routes(self.app, app_config)
|
||||||
self.app.json_encoder = Float32JSONEncoder
|
self.app.json_encoder = Float32JSONEncoder
|
||||||
server_config = app_config.server_config
|
server_config = app_config.server_config
|
||||||
@@ -353,6 +379,12 @@ class Server:
|
|||||||
self.app.register_blueprint(webbp)
|
self.app.register_blueprint(webbp)
|
||||||
|
|
||||||
api_version = "/api/v0.2"
|
api_version = "/api/v0.2"
|
||||||
|
api_base_url = server_config.get_api_base_url()
|
||||||
|
api_path = "/"
|
||||||
|
if api_base_url:
|
||||||
|
parse = urlparse(api_base_url)
|
||||||
|
api_path = parse.path
|
||||||
|
|
||||||
if app_config.is_multi_dataset():
|
if app_config.is_multi_dataset():
|
||||||
# NOTE: These routes only allow the dataset to be in the directory
|
# NOTE: These routes only allow the dataset to be in the directory
|
||||||
# of the dataroot, and not a subdirectory. We may want to change
|
# of the dataroot, and not a subdirectory. We may want to change
|
||||||
@@ -360,7 +392,8 @@ class Server:
|
|||||||
for dataroot_dict in server_config.multi_dataset__dataroot.values():
|
for dataroot_dict in server_config.multi_dataset__dataroot.values():
|
||||||
url_dataroot = dataroot_dict["base_url"]
|
url_dataroot = dataroot_dict["base_url"]
|
||||||
bp_api = Blueprint(
|
bp_api = Blueprint(
|
||||||
f"api_dataset_{url_dataroot}", __name__, url_prefix=f"/{url_dataroot}/<dataset>" + api_version
|
f"api_dataset_{url_dataroot}", __name__,
|
||||||
|
url_prefix=f"{api_path}/{url_dataroot}/<dataset>" + api_version
|
||||||
)
|
)
|
||||||
resources = get_api_resources(bp_api, url_dataroot)
|
resources = get_api_resources(bp_api, url_dataroot)
|
||||||
self.app.register_blueprint(resources.blueprint)
|
self.app.register_blueprint(resources.blueprint)
|
||||||
@@ -378,7 +411,7 @@ class Server:
|
|||||||
)
|
)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
bp_api = Blueprint("api", __name__, url_prefix=api_version)
|
bp_api = Blueprint("api", __name__, url_prefix=f"{api_path}{api_version}")
|
||||||
resources = get_api_resources(bp_api)
|
resources = get_api_resources(bp_api)
|
||||||
self.app.register_blueprint(resources.blueprint)
|
self.app.register_blueprint(resources.blueprint)
|
||||||
self.app.add_url_rule(
|
self.app.add_url_rule(
|
||||||
|
|||||||
+49
-33
@@ -1,7 +1,7 @@
|
|||||||
from flask import session, request, redirect, current_app, after_this_request, has_request_context, g
|
from flask import session, request, redirect, current_app, after_this_request, has_request_context, g
|
||||||
from server.auth.auth import AuthTypeClientBase, AuthTypeFactory
|
from server.auth.auth import AuthTypeClientBase, AuthTypeFactory
|
||||||
from server.common.errors import AuthenticationError, ConfigurationError
|
from server.common.errors import AuthenticationError, ConfigurationError
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode, urlparse
|
||||||
import json
|
import json
|
||||||
import requests
|
import requests
|
||||||
import base64
|
import base64
|
||||||
@@ -29,7 +29,9 @@ class Tokens:
|
|||||||
self.id_token = id_token
|
self.id_token = id_token
|
||||||
self.refresh_token = refresh_token
|
self.refresh_token = refresh_token
|
||||||
self.expires_at = expires_at
|
self.expires_at = expires_at
|
||||||
if not (access_token and id_token and refresh_token and expires_at):
|
|
||||||
|
# expires_at may be None after a token refresh, and so it is not checked here
|
||||||
|
if not (access_token and id_token and refresh_token):
|
||||||
raise KeyError(str(self.__dict__))
|
raise KeyError(str(self.__dict__))
|
||||||
|
|
||||||
|
|
||||||
@@ -43,13 +45,20 @@ class AuthTypeOAuth(AuthTypeClientBase):
|
|||||||
if missingimport:
|
if missingimport:
|
||||||
raise ConfigurationError(f"oauth requires these modules: {', '.join(missingimport)}")
|
raise ConfigurationError(f"oauth requires these modules: {', '.join(missingimport)}")
|
||||||
self.algorithms = ["RS256"]
|
self.algorithms = ["RS256"]
|
||||||
self.api_base_url = server_config.authentication__params_oauth__api_base_url
|
self.oauth_api_base_url = server_config.authentication__params_oauth__oauth_api_base_url
|
||||||
self.client_id = server_config.authentication__params_oauth__client_id
|
self.client_id = server_config.authentication__params_oauth__client_id
|
||||||
self.client_secret = server_config.authentication__params_oauth__client_secret
|
self.client_secret = server_config.authentication__params_oauth__client_secret
|
||||||
self.callback_base_url = server_config.authentication__params_oauth__callback_base_url
|
|
||||||
self.session_cookie = server_config.authentication__params_oauth__session_cookie
|
self.session_cookie = server_config.authentication__params_oauth__session_cookie
|
||||||
self.cookie_params = server_config.authentication__params_oauth__cookie
|
self.cookie_params = server_config.authentication__params_oauth__cookie
|
||||||
|
self.jwt_decode_options = server_config.authentication__params_oauth__jwt_decode_options
|
||||||
|
|
||||||
self._validate_cookie_params()
|
self._validate_cookie_params()
|
||||||
|
self._validate_jwt_decode_options()
|
||||||
|
|
||||||
|
self.api_base_url = server_config.get_api_base_url()
|
||||||
|
self.web_base_url = server_config.get_web_base_url()
|
||||||
|
if self.api_base_url is None:
|
||||||
|
raise ConfigurationError("oauth requires the app__api_base_url to be set")
|
||||||
|
|
||||||
# set the audience
|
# set the audience
|
||||||
self.audience = self.client_id
|
self.audience = self.client_id
|
||||||
@@ -58,11 +67,13 @@ class AuthTypeOAuth(AuthTypeClientBase):
|
|||||||
# The JSON Web Key Set (JWKS) is a set of keys which contains the public keys used to verify
|
# The JSON Web Key Set (JWKS) is a set of keys which contains the public keys used to verify
|
||||||
# any JSON Web Token (JWT) issued by the authorization server and signed using the RS256
|
# any JSON Web Token (JWT) issued by the authorization server and signed using the RS256
|
||||||
try:
|
try:
|
||||||
jwksloc = f"{self.api_base_url}/.well-known/jwks.json"
|
jwksloc = f"{self.oauth_api_base_url}/.well-known/jwks.json"
|
||||||
jwksurl = requests.get(jwksloc)
|
jwksurl = requests.get(jwksloc)
|
||||||
self.jwks = jwksurl.json()
|
self.jwks = jwksurl.json()
|
||||||
except Exception:
|
except Exception:
|
||||||
raise ConfigurationError(f"error in oauth, api_url_base: {self.api_base_url}, cannot access {jwksloc}")
|
raise ConfigurationError(
|
||||||
|
f"error in oauth, api_url_base: {self.oauth_api_base_url}, cannot access {jwksloc}"
|
||||||
|
)
|
||||||
|
|
||||||
def _validate_cookie_params(self):
|
def _validate_cookie_params(self):
|
||||||
"""check the cookie_params, and raise a ConfigurationError if there is something wrong"""
|
"""check the cookie_params, and raise a ConfigurationError if there is something wrong"""
|
||||||
@@ -79,6 +90,20 @@ class AuthTypeOAuth(AuthTypeClientBase):
|
|||||||
if "key" not in keys:
|
if "key" not in keys:
|
||||||
raise ConfigurationError("must have a key (name) in the cookie params")
|
raise ConfigurationError("must have a key (name) in the cookie params")
|
||||||
|
|
||||||
|
def _validate_jwt_decode_options(self):
|
||||||
|
"""check the jwt_decode_options, and raise a ConfigurationError if there is something wrong"""
|
||||||
|
if self.jwt_decode_options is None:
|
||||||
|
self.jwt_decode_options = {}
|
||||||
|
return
|
||||||
|
|
||||||
|
valid_keys = {
|
||||||
|
"verify_signature", "verify_aud", "verify_iat", "verify_exp", "verify_nbf", "verify_iss",
|
||||||
|
"verify_sub", "verify_jti", "verify_at_hash", "leeway"}
|
||||||
|
keys = set(self.jwt_decode_options.keys())
|
||||||
|
unknown = keys - valid_keys
|
||||||
|
if unknown:
|
||||||
|
raise ConfigurationError(f"unexpected key in jwt_decode_options: {', '.join(unknown)}")
|
||||||
|
|
||||||
def is_valid_authentication_type(self):
|
def is_valid_authentication_type(self):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -86,27 +111,22 @@ class AuthTypeOAuth(AuthTypeClientBase):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def add_url_rules(self, app):
|
def add_url_rules(self, app):
|
||||||
app.add_url_rule("/login", "login", self.login, methods=["GET"])
|
parse = urlparse(self.api_base_url)
|
||||||
app.add_url_rule("/logout", "logout", self.logout, methods=["GET"])
|
app.add_url_rule(f"{parse.path}/login", "login", self.login, methods=["GET"])
|
||||||
app.add_url_rule("/oauth2/callback", "callback", self.callback, methods=["GET"])
|
app.add_url_rule(f"{parse.path}/logout", "logout", self.logout, methods=["GET"])
|
||||||
|
app.add_url_rule(f"{parse.path}/oauth2/callback", "callback", self.callback, methods=["GET"])
|
||||||
|
|
||||||
def complete_setup(self, flask_app):
|
def complete_setup(self, flask_app):
|
||||||
self.oauth = OAuth(flask_app)
|
self.oauth = OAuth(flask_app)
|
||||||
if self.callback_base_url is None:
|
|
||||||
# In this case, assume the server is running on the same host as the client,
|
|
||||||
# and the oauth provider has been configured
|
|
||||||
# with a callback that understands a localhost callback (e.g. A http://localhost:5005).
|
|
||||||
server_config = flask_app.app_config.server_config
|
|
||||||
self.callback_base_url = f"http://{server_config.app__host}:{server_config.app__port}"
|
|
||||||
|
|
||||||
self.client = self.oauth.register(
|
self.client = self.oauth.register(
|
||||||
"auth0",
|
"auth0",
|
||||||
client_id=self.client_id,
|
client_id=self.client_id,
|
||||||
client_secret=self.client_secret,
|
client_secret=self.client_secret,
|
||||||
api_base_url=self.api_base_url,
|
api_base_url=self.oauth_api_base_url,
|
||||||
refresh_token_url=f"{self.api_base_url}/oauth/token",
|
refresh_token_url=f"{self.oauth_api_base_url}/oauth/token",
|
||||||
access_token_url=f"{self.api_base_url}/oauth/token",
|
access_token_url=f"{self.oauth_api_base_url}/oauth/token",
|
||||||
authorize_url=f"{self.api_base_url}/authorize",
|
authorize_url=f"{self.oauth_api_base_url}/authorize",
|
||||||
client_kwargs={"scope": "openid profile email offline_access"},
|
client_kwargs={"scope": "openid profile email offline_access"},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -136,9 +156,9 @@ class AuthTypeOAuth(AuthTypeClientBase):
|
|||||||
response.cache_control.update(dict(public=True, max_age=0, no_store=True, no_cache=True, must_revalidate=True))
|
response.cache_control.update(dict(public=True, max_age=0, no_store=True, no_cache=True, must_revalidate=True))
|
||||||
|
|
||||||
def login(self):
|
def login(self):
|
||||||
callbackurl = f"{self.callback_base_url}/oauth2/callback"
|
callbackurl = f"{self.api_base_url}/oauth2/callback"
|
||||||
return_path = request.args.get("dataset", "")
|
return_path = request.args.get("dataset", "")
|
||||||
return_to = f"{self.callback_base_url}/{return_path}"
|
return_to = f"{self.web_base_url}/{return_path}/"
|
||||||
# save the return path in the session cookie, accessed in the callback function
|
# save the return path in the session cookie, accessed in the callback function
|
||||||
session["oauth_callback_redirect"] = return_to
|
session["oauth_callback_redirect"] = return_to
|
||||||
response = self.client.authorize_redirect(redirect_uri=callbackurl)
|
response = self.client.authorize_redirect(redirect_uri=callbackurl)
|
||||||
@@ -147,7 +167,7 @@ class AuthTypeOAuth(AuthTypeClientBase):
|
|||||||
|
|
||||||
def logout(self):
|
def logout(self):
|
||||||
self.remove_tokens()
|
self.remove_tokens()
|
||||||
params = {"returnTo": self.callback_base_url, "client_id": self.client_id}
|
params = {"returnTo": self.web_base_url, "client_id": self.client_id}
|
||||||
response = redirect(self.client.api_base_url + "/v2/logout?" + urlencode(params))
|
response = redirect(self.client.api_base_url + "/v2/logout?" + urlencode(params))
|
||||||
self.update_response(response)
|
self.update_response(response)
|
||||||
return response
|
return response
|
||||||
@@ -226,14 +246,14 @@ class AuthTypeOAuth(AuthTypeClientBase):
|
|||||||
|
|
||||||
def get_login_url(self, data_adaptor):
|
def get_login_url(self, data_adaptor):
|
||||||
"""Return the url for the login route"""
|
"""Return the url for the login route"""
|
||||||
if current_app.app_config.is_multi_dataset():
|
if data_adaptor and current_app.app_config.is_multi_dataset():
|
||||||
return f"/login?dataset={data_adaptor.uri_path}/"
|
return f"{self.api_base_url}/login?dataset={data_adaptor.uri_path}/"
|
||||||
else:
|
else:
|
||||||
return "/login"
|
return f"{self.api_base_url}/login"
|
||||||
|
|
||||||
def get_logout_url(self, data_adaptor):
|
def get_logout_url(self, data_adaptor):
|
||||||
"""Return the url for the logout route"""
|
"""Return the url for the logout route"""
|
||||||
return "/logout"
|
return f"{self.api_base_url}/logout"
|
||||||
|
|
||||||
def check_jwt_payload(self, id_token):
|
def check_jwt_payload(self, id_token):
|
||||||
try:
|
try:
|
||||||
@@ -252,18 +272,14 @@ class AuthTypeOAuth(AuthTypeClientBase):
|
|||||||
"e": key.get("e"),
|
"e": key.get("e"),
|
||||||
}
|
}
|
||||||
if rsa_key:
|
if rsa_key:
|
||||||
options = {}
|
|
||||||
if not rsa_key["n"] or not rsa_key["e"]:
|
|
||||||
# this is a mock auth server, do not validate
|
|
||||||
options = {"verify_signature": False, "verify_iss": False}
|
|
||||||
try:
|
try:
|
||||||
payload = jwt.decode(
|
payload = jwt.decode(
|
||||||
id_token,
|
id_token,
|
||||||
rsa_key,
|
rsa_key,
|
||||||
algorithms=self.algorithms,
|
algorithms=self.algorithms,
|
||||||
audience=self.audience,
|
audience=self.audience,
|
||||||
issuer=self.api_base_url + "/",
|
issuer=self.oauth_api_base_url + "/",
|
||||||
options=options,
|
options=self.jwt_decode_options,
|
||||||
)
|
)
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
@@ -319,7 +335,7 @@ class AuthTypeOAuth(AuthTypeClientBase):
|
|||||||
"client_secret": self.client_secret,
|
"client_secret": self.client_secret,
|
||||||
}
|
}
|
||||||
headers = {"content-type": "application/x-www-form-urlencoded"}
|
headers = {"content-type": "application/x-www-form-urlencoded"}
|
||||||
request = requests.post(f"{self.api_base_url}/oauth/token", urlencode(params), headers=headers)
|
request = requests.post(f"{self.oauth_api_base_url}/oauth/token", urlencode(params), headers=headers)
|
||||||
if request.status_code != 200:
|
if request.status_code != 200:
|
||||||
# unable to refresh the token, log the user out
|
# unable to refresh the token, log the user out
|
||||||
self.remove_tokens()
|
self.remove_tokens()
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
import click
|
|
||||||
import re
|
import re
|
||||||
import requests
|
|
||||||
|
|
||||||
|
import click
|
||||||
|
import requests
|
||||||
from requests.exceptions import ConnectionError
|
from requests.exceptions import ConnectionError
|
||||||
|
|
||||||
from .. import __version__
|
from .. import __version__
|
||||||
|
|
||||||
# Official SemVer regex: https://semver.org/
|
# Official SemVer regex: https://semver.org/
|
||||||
SEMVER_FORMAT = re.compile(
|
SEMVER_FORMAT = re.compile(
|
||||||
r"^(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)"
|
r"^(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)(?:-(?P<prerelease>(?:0|[1-9]\d*|\d*["
|
||||||
+ r"(?:-(?P<prerelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)"
|
r"a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P<buildmetadata>[0-9a-zA-Z-]+("
|
||||||
+ r"(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?"
|
r"?:\.[0-9a-zA-Z-]+)*))?$")
|
||||||
+ r"(?:\+(?P<buildmetadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def log_upgrade_check():
|
def log_upgrade_check():
|
||||||
@@ -23,8 +22,8 @@ def log_upgrade_check():
|
|||||||
release_tag_generator = (r["tag_name"] for r in _request_cellxgene_releases())
|
release_tag_generator = (r["tag_name"] for r in _request_cellxgene_releases())
|
||||||
latest_release = next(release_tag_generator, lambda tag_name: validate_version_str(tag_name))
|
latest_release = next(release_tag_generator, lambda tag_name: validate_version_str(tag_name))
|
||||||
if version_gt(latest_release, __version__):
|
if version_gt(latest_release, __version__):
|
||||||
click.echo(f"There's a new version of cellxgene available ({latest_release})!")
|
click.echo(f"There's a new version of cellxgene available ({latest_release})!", err=True)
|
||||||
click.echo("To upgrade, run the following: pip install --upgrade cellxgene\n")
|
click.echo("To upgrade, run the following: pip install --upgrade cellxgene\n", err=True)
|
||||||
except (ConnectionError, RateLimitException):
|
except (ConnectionError, RateLimitException):
|
||||||
click.echo("Upgrade check failed.\n")
|
click.echo("Upgrade check failed.\n")
|
||||||
|
|
||||||
|
|||||||
+45
-10
@@ -9,6 +9,7 @@ import yaml
|
|||||||
from flatten_dict import flatten, unflatten
|
from flatten_dict import flatten, unflatten
|
||||||
|
|
||||||
import server.compute.diffexp_cxg as diffexp_tiledb
|
import server.compute.diffexp_cxg as diffexp_tiledb
|
||||||
|
import server.compute.scanpy
|
||||||
from server import display_version as cellxgene_display_version
|
from server import display_version as cellxgene_display_version
|
||||||
from server.auth.auth import AuthTypeFactory
|
from server.auth.auth import AuthTypeFactory
|
||||||
from server.common.annotations.hosted_tiledb import AnnotationsHostedTileDB
|
from server.common.annotations.hosted_tiledb import AnnotationsHostedTileDB
|
||||||
@@ -99,7 +100,10 @@ class AppConfig(object):
|
|||||||
|
|
||||||
per_dataset_config = config.get("per_dataset_config", {})
|
per_dataset_config = config.get("per_dataset_config", {})
|
||||||
for key, dataroot_config in per_dataset_config.items():
|
for key, dataroot_config in per_dataset_config.items():
|
||||||
self.add_dataroot_config(key, **dataroot_config)
|
# first create and initialize the dataroot with the default config
|
||||||
|
self.add_dataroot_config(key, **config["dataset"])
|
||||||
|
# then apply the per dataset configuration
|
||||||
|
self.dataroot_config[key].update_from_config(dataroot_config, f"per_dataset_config__{key}")
|
||||||
|
|
||||||
self.is_complete = False
|
self.is_complete = False
|
||||||
|
|
||||||
@@ -416,6 +420,7 @@ class ServerConfig(BaseConfig):
|
|||||||
dictval_cases = [
|
dictval_cases = [
|
||||||
("app", "csp_directives"),
|
("app", "csp_directives"),
|
||||||
("authentication", "params_oauth", "cookie"),
|
("authentication", "params_oauth", "cookie"),
|
||||||
|
("authentication", "params_oauth", "jwt_decode_options"),
|
||||||
("adaptor", "cxg_adaptor", "tiledb_ctx"),
|
("adaptor", "cxg_adaptor", "tiledb_ctx"),
|
||||||
("multi_dataset", "dataroot"),
|
("multi_dataset", "dataroot"),
|
||||||
]
|
]
|
||||||
@@ -433,13 +438,17 @@ class ServerConfig(BaseConfig):
|
|||||||
self.app__generate_cache_control_headers = dc["app"]["generate_cache_control_headers"]
|
self.app__generate_cache_control_headers = dc["app"]["generate_cache_control_headers"]
|
||||||
self.app__server_timing_headers = dc["app"]["server_timing_headers"]
|
self.app__server_timing_headers = dc["app"]["server_timing_headers"]
|
||||||
self.app__csp_directives = dc["app"]["csp_directives"]
|
self.app__csp_directives = dc["app"]["csp_directives"]
|
||||||
|
self.app__api_base_url = dc["app"]["api_base_url"]
|
||||||
|
self.app__web_base_url = dc["app"]["web_base_url"]
|
||||||
|
|
||||||
self.authentication__type = dc["authentication"]["type"]
|
self.authentication__type = dc["authentication"]["type"]
|
||||||
self.authentication__params_oauth__api_base_url = dc["authentication"]["params_oauth"]["api_base_url"]
|
self.authentication__params_oauth__oauth_api_base_url = dc["authentication"]["params_oauth"][
|
||||||
|
"oauth_api_base_url"
|
||||||
|
]
|
||||||
self.authentication__params_oauth__client_id = dc["authentication"]["params_oauth"]["client_id"]
|
self.authentication__params_oauth__client_id = dc["authentication"]["params_oauth"]["client_id"]
|
||||||
self.authentication__params_oauth__client_secret = dc["authentication"]["params_oauth"]["client_secret"]
|
self.authentication__params_oauth__client_secret = dc["authentication"]["params_oauth"]["client_secret"]
|
||||||
self.authentication__params_oauth__callback_base_url = \
|
self.authentication__params_oauth__jwt_decode_options = dc["authentication"]["params_oauth"][
|
||||||
dc["authentication"]["params_oauth"]["callback_base_url"]
|
"jwt_decode_options"]
|
||||||
self.authentication__params_oauth__session_cookie = dc["authentication"]["params_oauth"]["session_cookie"]
|
self.authentication__params_oauth__session_cookie = dc["authentication"]["params_oauth"]["session_cookie"]
|
||||||
self.authentication__params_oauth__cookie = dc["authentication"]["params_oauth"]["cookie"]
|
self.authentication__params_oauth__cookie = dc["authentication"]["params_oauth"]["cookie"]
|
||||||
|
|
||||||
@@ -500,6 +509,8 @@ class ServerConfig(BaseConfig):
|
|||||||
self.check_attr("app__generate_cache_control_headers", bool)
|
self.check_attr("app__generate_cache_control_headers", bool)
|
||||||
self.check_attr("app__server_timing_headers", bool)
|
self.check_attr("app__server_timing_headers", bool)
|
||||||
self.check_attr("app__csp_directives", (type(None), dict))
|
self.check_attr("app__csp_directives", (type(None), dict))
|
||||||
|
self.check_attr("app__api_base_url", (type(None), str))
|
||||||
|
self.check_attr("app__web_base_url", (type(None), str))
|
||||||
|
|
||||||
if self.app__port:
|
if self.app__port:
|
||||||
try:
|
try:
|
||||||
@@ -548,15 +559,18 @@ class ServerConfig(BaseConfig):
|
|||||||
elif not isinstance(v, str):
|
elif not isinstance(v, str):
|
||||||
raise ConfigurationError("CSP directive value must be a string or list of strings.")
|
raise ConfigurationError("CSP directive value must be a string or list of strings.")
|
||||||
|
|
||||||
|
if self.app__web_base_url is None:
|
||||||
|
self.app__web_base_url = self.app__api_base_url
|
||||||
|
|
||||||
def handle_authentication(self, context):
|
def handle_authentication(self, context):
|
||||||
self.check_attr("authentication__type", (type(None), str))
|
self.check_attr("authentication__type", (type(None), str))
|
||||||
|
|
||||||
# oauth
|
# oauth
|
||||||
ptypes = str if self.authentication__type == "oauth" else (type(None), str)
|
ptypes = str if self.authentication__type == "oauth" else (type(None), str)
|
||||||
self.check_attr("authentication__params_oauth__api_base_url", ptypes)
|
self.check_attr("authentication__params_oauth__oauth_api_base_url", ptypes)
|
||||||
self.check_attr("authentication__params_oauth__client_id", ptypes)
|
self.check_attr("authentication__params_oauth__client_id", ptypes)
|
||||||
self.check_attr("authentication__params_oauth__client_secret", ptypes)
|
self.check_attr("authentication__params_oauth__client_secret", ptypes)
|
||||||
self.check_attr("authentication__params_oauth__callback_base_url", (type(None), str))
|
self.check_attr("authentication__params_oauth__jwt_decode_options", (type(None), dict))
|
||||||
self.check_attr("authentication__params_oauth__session_cookie", bool)
|
self.check_attr("authentication__params_oauth__session_cookie", bool)
|
||||||
|
|
||||||
if self.authentication__params_oauth__session_cookie:
|
if self.authentication__params_oauth__session_cookie:
|
||||||
@@ -742,6 +756,22 @@ class ServerConfig(BaseConfig):
|
|||||||
return False
|
return False
|
||||||
return value > limit_value
|
return value > limit_value
|
||||||
|
|
||||||
|
def get_api_base_url(self):
|
||||||
|
if self.app__api_base_url == "local":
|
||||||
|
return f"http://{self.app__host}:{self.app__port}"
|
||||||
|
if self.app__api_base_url and self.app__api_base_url.endswith("/"):
|
||||||
|
return self.app__api_base_url[:-1]
|
||||||
|
return self.app__api_base_url
|
||||||
|
|
||||||
|
def get_web_base_url(self):
|
||||||
|
if self.app__web_base_url == "local":
|
||||||
|
return f"http://{self.app__host}:{self.app__port}"
|
||||||
|
if self.app__web_base_url is None:
|
||||||
|
return self.get_api_base_url()
|
||||||
|
if self.app__web_base_url.endswith("/"):
|
||||||
|
return self.app__web_base_url[:-1]
|
||||||
|
return self.api__web_base_url
|
||||||
|
|
||||||
|
|
||||||
class DatasetConfig(BaseConfig):
|
class DatasetConfig(BaseConfig):
|
||||||
"""Manages the config attribute associated with a dataset."""
|
"""Manages the config attribute associated with a dataset."""
|
||||||
@@ -768,7 +798,7 @@ class DatasetConfig(BaseConfig):
|
|||||||
self.user_annotations__ontology__obo_location = dc["user_annotations"]["ontology"]["obo_location"]
|
self.user_annotations__ontology__obo_location = dc["user_annotations"]["ontology"]["obo_location"]
|
||||||
self.user_annotations__hosted_tiledb_array__db_uri = dc["user_annotations"]["hosted_tiledb_array"]["db_uri"]
|
self.user_annotations__hosted_tiledb_array__db_uri = dc["user_annotations"]["hosted_tiledb_array"]["db_uri"]
|
||||||
self.user_annotations__hosted_tiledb_array__hosted_file_directory = \
|
self.user_annotations__hosted_tiledb_array__hosted_file_directory = \
|
||||||
dc["user_annotations"]["hosted_tiledb_array"]["hosted_file_directory"] # noqa E501
|
dc["user_annotations"][ "hosted_tiledb_array" ][ "hosted_file_directory" ] # noqa E501
|
||||||
|
|
||||||
self.embeddings__names = dc["embeddings"]["names"]
|
self.embeddings__names = dc["embeddings"]["names"]
|
||||||
self.embeddings__enable_reembedding = dc["embeddings"]["enable_reembedding"]
|
self.embeddings__enable_reembedding = dc["embeddings"]["enable_reembedding"]
|
||||||
@@ -898,16 +928,21 @@ class DatasetConfig(BaseConfig):
|
|||||||
self.check_attr("embeddings__enable_reembedding", bool)
|
self.check_attr("embeddings__enable_reembedding", bool)
|
||||||
|
|
||||||
server_config = self.app_config.server_config
|
server_config = self.app_config.server_config
|
||||||
if server_config.single_dataset__datapath:
|
if self.embeddings__enable_reembedding:
|
||||||
if self.embeddings__enable_reembedding:
|
if server_config.single_dataset__datapath:
|
||||||
matrix_data_loader = MatrixDataLoader(
|
matrix_data_loader = MatrixDataLoader(
|
||||||
server_config.single_dataset__datapath, app_config=self.app_config
|
server_config.single_dataset__datapath, app_config=self.app_config
|
||||||
)
|
)
|
||||||
if matrix_data_loader.matrix_data_type != MatrixDataType.H5AD:
|
if matrix_data_loader.matrix_data_type != MatrixDataType.H5AD:
|
||||||
raise ConfigurationError("'enable-reembedding is only supported with H5AD files.")
|
raise ConfigurationError("enable-reembedding is only supported with H5AD files.")
|
||||||
if server_config.adaptor__anndata_adaptor__backed:
|
if server_config.adaptor__anndata_adaptor__backed:
|
||||||
raise ConfigurationError("enable-reembedding is not supported when run in --backed mode.")
|
raise ConfigurationError("enable-reembedding is not supported when run in --backed mode.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
server.compute.scanpy.get_scanpy_module()
|
||||||
|
except NotImplementedError:
|
||||||
|
raise ConfigurationError("Please install scanpy to enable UMAP re-embedding")
|
||||||
|
|
||||||
def handle_diffexp(self, context):
|
def handle_diffexp(self, context):
|
||||||
self.check_attr("diffexp__enable", bool)
|
self.check_attr("diffexp__enable", bool)
|
||||||
self.check_attr("diffexp__lfc_cutoff", float)
|
self.check_attr("diffexp__lfc_cutoff", float)
|
||||||
|
|||||||
@@ -14,6 +14,20 @@ server:
|
|||||||
server_timing_headers: false
|
server_timing_headers: false
|
||||||
csp_directives: null
|
csp_directives: null
|
||||||
|
|
||||||
|
# By default, cellxgene will serve api requests from the same base url as the webpage.
|
||||||
|
# In general api_base_url and web_base_url will not need to be set.
|
||||||
|
# There are two reasons to set these parameters:
|
||||||
|
# 1. Oauth authentication is used; the oauth server will redirect back to the api_base_url after login,
|
||||||
|
# which then redirects back to the web_base_url. If the web_base_url is not set, it will default to
|
||||||
|
# the api_base_url. If oauth authentication is used, the api_base_url must be set.
|
||||||
|
# For a local test (where the server runs on "http://localhost:<port>"), then the api_base_url may be
|
||||||
|
# set to the string "local".
|
||||||
|
# 2. The cellxgene deploymnent is in an environment where the webpage and api have
|
||||||
|
# different base urls. In this case both api_base_url and web_base_url must be set.
|
||||||
|
# It is up to the server admin to ensure that the networking is setup correctly for this environment.
|
||||||
|
api_base_url: null
|
||||||
|
web_base_url: null
|
||||||
|
|
||||||
authentication:
|
authentication:
|
||||||
# The authentication types may be "none", "session", "oauth"
|
# The authentication types may be "none", "session", "oauth"
|
||||||
# none: No authentication support, features like user_annotations must not be enabled.
|
# none: No authentication support, features like user_annotations must not be enabled.
|
||||||
@@ -22,16 +36,17 @@ server:
|
|||||||
type: session
|
type: session
|
||||||
|
|
||||||
params_oauth:
|
params_oauth:
|
||||||
# url to the auth server
|
# url to the oauth server
|
||||||
api_base_url: null
|
oauth_api_base_url: null
|
||||||
# client_id of this app
|
# client_id of this app
|
||||||
client_id: null
|
client_id: null
|
||||||
# the client_secret known to the auth server and this app
|
# the client_secret known to the auth server and this app
|
||||||
client_secret: null
|
client_secret: null
|
||||||
# cellxgene server location;
|
# jwt_decode_options, to specify non default decode options define
|
||||||
# the browser will be redirected to locations relative to this location during login and logout.
|
# jwt_decode_options to be a dictionary with key/values described by
|
||||||
# A value of None, indicates the client and server are on the localhost. http://localhost:<port> will be used.
|
# the options parameter of the jose.jwt.decode function:
|
||||||
callback_base_url: null
|
# (https://python-jose.readthedocs.io/en/latest/jwt/api.html)
|
||||||
|
jwt_decode_options: null
|
||||||
|
|
||||||
# if true, the jwt containing the id_token is stored in a session cookie
|
# if true, the jwt containing the id_token is stored in a session cookie
|
||||||
session_cookie: true
|
session_cookie: true
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ def scanpy_umap(adata, obs_mask=None, pca_options={}, neighbors_options={}, umap
|
|||||||
for k in list(adata.uns.keys()):
|
for k in list(adata.uns.keys()):
|
||||||
del adata.uns[k]
|
del adata.uns[k]
|
||||||
|
|
||||||
sc.pp.pca(adata, zero_center=None, n_comps=min(adata.n_obs - 1, 50), **pca_options)
|
sc.pp.pca(adata, zero_center=None, n_comps=min(adata.n_vars - 1, 50), **pca_options)
|
||||||
sc.pp.neighbors(adata, **neighbors_options)
|
sc.pp.neighbors(adata, **neighbors_options)
|
||||||
sc.tl.umap(adata, **umap_options)
|
sc.tl.umap(adata, **umap_options)
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -34,7 +34,8 @@ build: clean
|
|||||||
cp customize/requirements.txt artifact.dir; \
|
cp customize/requirements.txt artifact.dir; \
|
||||||
fi ; \
|
fi ; \
|
||||||
if [ -d customize/deploy ] ; then \
|
if [ -d customize/deploy ] ; then \
|
||||||
cp -r customize/deploy artifact.dir/server/common/web/static; \
|
mkdir -p artifact.dir/server/common/web/static/cellxgene; \
|
||||||
|
cp -r customize/deploy artifact.dir/server/common/web/static/cellxgene; \
|
||||||
fi; \
|
fi; \
|
||||||
if [ -d customize/inline_scripts ] ; then \
|
if [ -d customize/inline_scripts ] ; then \
|
||||||
cp -r customize/inline_scripts/* artifact.dir/server/common/web/templates; \
|
cp -r customize/inline_scripts/* artifact.dir/server/common/web/templates; \
|
||||||
|
|||||||
+4
-4
@@ -95,7 +95,7 @@ To use this feature, do the following:
|
|||||||
|
|
||||||
* In this directory, create a sub directory called "customize/deploy/".
|
* In this directory, create a sub directory called "customize/deploy/".
|
||||||
* Copy the files you want to serve into this directory
|
* Copy the files you want to serve into this directory
|
||||||
* modify your configuration file to set the location to these file: /static/deploy/<filename>
|
* modify your configuration file to set the location to these file: /static/cellxgene/deploy/<filename>
|
||||||
|
|
||||||
Example: you want to include an "about_legal_tos" and "about_legal_privacy" page to cellxgene.
|
Example: you want to include an "about_legal_tos" and "about_legal_privacy" page to cellxgene.
|
||||||
Assume files called "tos.html" and "privacy.html" exist.
|
Assume files called "tos.html" and "privacy.html" exist.
|
||||||
@@ -106,9 +106,9 @@ $ cp <source_dir>/tos.html customize/deploy/tos.html
|
|||||||
$ cp <source_dir>/privacy.html customize/deploy/privacy.html
|
$ cp <source_dir>/privacy.html customize/deploy/privacy.html
|
||||||
|
|
||||||
# edit config.yaml
|
# edit config.yaml
|
||||||
$ grep "/static/deploy" config.yaml
|
$ grep "/static/cellxgene/deploy" config.yaml
|
||||||
about_legal_tos: /static/deploy/tos.html
|
about_legal_tos: /static/cellxgene/deploy/tos.html
|
||||||
about_legal_privacy: /static/deploy/privacy.html
|
about_legal_privacy: /static/cellxgene/deploy/privacy.html
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Inline javascript scripts
|
#### Inline javascript scripts
|
||||||
|
|||||||
+18
-2
@@ -4,10 +4,11 @@ import sys
|
|||||||
import os
|
import os
|
||||||
import hashlib
|
import hashlib
|
||||||
import base64
|
import base64
|
||||||
|
from urllib.parse import urlparse
|
||||||
from flask import json
|
from flask import json
|
||||||
import logging
|
import logging
|
||||||
from flask_talisman import Talisman
|
from flask_talisman import Talisman
|
||||||
|
from flask_cors import CORS
|
||||||
from server.common.aws_secret_utils import handle_config_from_secret
|
from server.common.aws_secret_utils import handle_config_from_secret
|
||||||
from server.common.errors import SecretKeyRetrievalError
|
from server.common.errors import SecretKeyRetrievalError
|
||||||
|
|
||||||
@@ -41,6 +42,14 @@ class WSGIServer(Server):
|
|||||||
def _before_adding_routes(app, app_config):
|
def _before_adding_routes(app, app_config):
|
||||||
script_hashes = WSGIServer.get_csp_hashes(app, app_config)
|
script_hashes = WSGIServer.get_csp_hashes(app, app_config)
|
||||||
server_config = app_config.server_config
|
server_config = app_config.server_config
|
||||||
|
|
||||||
|
# add the api_base_url to the connect_src csp header.
|
||||||
|
extra_connect_src = []
|
||||||
|
api_base_url = server_config.get_api_base_url()
|
||||||
|
if api_base_url:
|
||||||
|
parse_api_base_url = urlparse(api_base_url)
|
||||||
|
extra_connect_src = [f"{parse_api_base_url.scheme}://{parse_api_base_url.netloc}"]
|
||||||
|
|
||||||
# This hash should be in sync with the script within
|
# This hash should be in sync with the script within
|
||||||
# `client/configuration/webpack/obsoleteHTMLTemplate.html`
|
# `client/configuration/webpack/obsoleteHTMLTemplate.html`
|
||||||
|
|
||||||
@@ -51,7 +60,7 @@ class WSGIServer(Server):
|
|||||||
obsolete_browser_script_hash = ["'sha256-/rmgOi/skq9MpiZxPv6lPb1PNSN+Uf4NaUHO/IjyfwM='"]
|
obsolete_browser_script_hash = ["'sha256-/rmgOi/skq9MpiZxPv6lPb1PNSN+Uf4NaUHO/IjyfwM='"]
|
||||||
csp = {
|
csp = {
|
||||||
"default-src": ["'self'"],
|
"default-src": ["'self'"],
|
||||||
"connect-src": ["'self'"],
|
"connect-src": ["'self'"] + extra_connect_src,
|
||||||
"script-src": ["'self'", "'unsafe-eval'"]
|
"script-src": ["'self'", "'unsafe-eval'"]
|
||||||
+ obsolete_browser_script_hash + script_hashes,
|
+ obsolete_browser_script_hash + script_hashes,
|
||||||
"style-src": ["'self'", "'unsafe-inline'"],
|
"style-src": ["'self'", "'unsafe-inline'"],
|
||||||
@@ -70,6 +79,13 @@ class WSGIServer(Server):
|
|||||||
v = [v]
|
v = [v]
|
||||||
csp[k] = csp.get(k, []) + v
|
csp[k] = csp.get(k, []) + v
|
||||||
|
|
||||||
|
# Add the web_base_url to the CORS header
|
||||||
|
web_base_url = server_config.get_web_base_url()
|
||||||
|
if web_base_url:
|
||||||
|
web_base_url_parse = urlparse(web_base_url)
|
||||||
|
allowed_origin = f"{web_base_url_parse.scheme}://{web_base_url_parse.netloc}"
|
||||||
|
CORS(app, supports_credentials=True, origins=allowed_origin)
|
||||||
|
|
||||||
Talisman(
|
Talisman(
|
||||||
app, force_https=server_config.app__force_https, frame_options="DENY", content_security_policy=csp,
|
app, force_https=server_config.app__force_https, frame_options="DENY", content_security_policy=csp,
|
||||||
)
|
)
|
||||||
|
|||||||
+13
-5
@@ -131,16 +131,24 @@ def start_test_server(command_line_args=[], app_config=None):
|
|||||||
|
|
||||||
where the server can be accessed within the context, and is terminated when
|
where the server can be accessed within the context, and is terminated when
|
||||||
the context is exited.
|
the context is exited.
|
||||||
The port is automatically set using find_available_port.
|
The port is automatically set using find_available_port, unless passed in as a command line arg.
|
||||||
The verbose flag is automatically set to True.
|
The verbose flag is automatically set to True.
|
||||||
If an app_config is provided, then this function writes a temporary
|
If an app_config is provided, then this function writes a temporary
|
||||||
yaml config file, which this server will read and parse.
|
yaml config file, which this server will read and parse.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
start = random.randint(DEFAULT_SERVER_PORT, 2 ** 16 - 1)
|
command = ["cellxgene", "--no-upgrade-check", "launch", "--verbose"]
|
||||||
port = int(os.environ.get("CXG_SERVER_PORT", start))
|
if "-p" in command_line_args:
|
||||||
port = find_available_port("localhost", port)
|
port = int(command_line_args[command_line_args.index("-p") + 1])
|
||||||
command = ["cellxgene", "--no-upgrade-check", "launch", "--verbose", "--port=%d" % port] + command_line_args
|
elif "--port" in command_line_args:
|
||||||
|
port = int(command_line_args[command_line_args.index("--port") + 1])
|
||||||
|
else:
|
||||||
|
start = random.randint(DEFAULT_SERVER_PORT, 2 ** 16 - 1)
|
||||||
|
port = int(os.environ.get("CXG_SERVER_PORT", start))
|
||||||
|
port = find_available_port("localhost", port)
|
||||||
|
command += ["--port=%d" % port]
|
||||||
|
|
||||||
|
command += command_line_args
|
||||||
|
|
||||||
tempdir = None
|
tempdir = None
|
||||||
if app_config:
|
if app_config:
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -19,7 +19,7 @@ from server.test import FIXTURES_ROOT, test_server
|
|||||||
# oauth server.
|
# oauth server.
|
||||||
|
|
||||||
# number of seconds that the oauth token is valid
|
# number of seconds that the oauth token is valid
|
||||||
TOKEN_EXPIRES = 5
|
TOKEN_EXPIRES = 2
|
||||||
|
|
||||||
# Create a mocked out oauth token, which servers all the endpoints needed by the oauth type.
|
# Create a mocked out oauth token, which servers all the endpoints needed by the oauth type.
|
||||||
mock_oauth_app = Flask("mock_oauth_app")
|
mock_oauth_app = Flask("mock_oauth_app")
|
||||||
@@ -34,17 +34,19 @@ def authorize():
|
|||||||
|
|
||||||
@mock_oauth_app.route("/oauth/token", methods=["POST"])
|
@mock_oauth_app.route("/oauth/token", methods=["POST"])
|
||||||
def token():
|
def token():
|
||||||
|
now = time.time()
|
||||||
|
expires_at = now + TOKEN_EXPIRES
|
||||||
headers = dict(alg="RS256", kid="fake_kid")
|
headers = dict(alg="RS256", kid="fake_kid")
|
||||||
payload = dict(name="fake_user", sub="fake_id", email="fake_user@email.com", email_verified=True)
|
payload = dict(name="fake_user", sub="fake_id", email="fake_user@email.com", email_verified=True, exp=expires_at)
|
||||||
jwt = jose.jwt.encode(claims=payload, key="mysecret", algorithm="HS256", headers=headers)
|
jwt = jose.jwt.encode(claims=payload, key="mysecret", algorithm="HS256", headers=headers)
|
||||||
r = {
|
r = {
|
||||||
"access_token": f"access-{time.time()}",
|
"access_token": f"access-{now}",
|
||||||
"id_token": jwt,
|
"id_token": jwt,
|
||||||
"refresh_token": f"random-{time.time()}",
|
"refresh_token": f"random-{now}",
|
||||||
"scope": "openid profile email",
|
"scope": "openid profile email",
|
||||||
"expires_in": TOKEN_EXPIRES,
|
"expires_in": TOKEN_EXPIRES,
|
||||||
"token_type": "Bearer",
|
"token_type": "Bearer",
|
||||||
"expires_at": time.time() + TOKEN_EXPIRES,
|
"expires_at": expires_at
|
||||||
}
|
}
|
||||||
return make_response(jsonify(r))
|
return make_response(jsonify(r))
|
||||||
|
|
||||||
@@ -81,6 +83,19 @@ class AuthTest(unittest.TestCase):
|
|||||||
|
|
||||||
def auth_flow(self, app_config, cookie_key=None):
|
def auth_flow(self, app_config, cookie_key=None):
|
||||||
|
|
||||||
|
app_config.update_server_config(
|
||||||
|
app__api_base_url="local",
|
||||||
|
authentication__type="oauth",
|
||||||
|
authentication__params_oauth__oauth_api_base_url=f"http://localhost:{PORT}",
|
||||||
|
authentication__params_oauth__client_id="mock_client_id",
|
||||||
|
authentication__params_oauth__client_secret="mock_client_secret",
|
||||||
|
authentication__params_oauth__jwt_decode_options={
|
||||||
|
"verify_signature": False, "verify_iss": False
|
||||||
|
})
|
||||||
|
|
||||||
|
app_config.update_server_config(multi_dataset__dataroot=self.dataset_dataroot)
|
||||||
|
app_config.complete_config()
|
||||||
|
|
||||||
with test_server(app_config=app_config) as server:
|
with test_server(app_config=app_config) as server:
|
||||||
session = requests.Session()
|
session = requests.Session()
|
||||||
|
|
||||||
@@ -96,10 +111,10 @@ class AuthTest(unittest.TestCase):
|
|||||||
login_uri = config["config"]["authentication"]["login"]
|
login_uri = config["config"]["authentication"]["login"]
|
||||||
logout_uri = config["config"]["authentication"]["logout"]
|
logout_uri = config["config"]["authentication"]["logout"]
|
||||||
|
|
||||||
self.assertEqual(login_uri, "/login?dataset=d/pbmc3k.cxg/")
|
self.assertEqual(login_uri, f"{server}/login?dataset=d/pbmc3k.cxg/")
|
||||||
self.assertEqual(logout_uri, "/logout")
|
self.assertEqual(logout_uri, f"{server}/logout")
|
||||||
|
|
||||||
r = session.get(f"{server}/{login_uri}")
|
r = session.get(login_uri)
|
||||||
# check that the login redirect worked
|
# check that the login redirect worked
|
||||||
self.assertEqual(r.history[0].status_code, 302)
|
self.assertEqual(r.history[0].status_code, 302)
|
||||||
self.assertEqual(r.url, f"{server}/d/pbmc3k.cxg/")
|
self.assertEqual(r.url, f"{server}/d/pbmc3k.cxg/")
|
||||||
@@ -113,13 +128,13 @@ class AuthTest(unittest.TestCase):
|
|||||||
cookie = session.cookies.get(cookie_key)
|
cookie = session.cookies.get(cookie_key)
|
||||||
token = json.loads(base64.b64decode(cookie))
|
token = json.loads(base64.b64decode(cookie))
|
||||||
access_token_before = token.get("access_token")
|
access_token_before = token.get("access_token")
|
||||||
expires_at_before = token.get("expires_at")
|
id_token_before = token.get("id_token")
|
||||||
|
|
||||||
# let the token expire
|
# let the token expire
|
||||||
time.sleep(TOKEN_EXPIRES + 1)
|
time.sleep(TOKEN_EXPIRES + 1)
|
||||||
|
|
||||||
# check that refresh works
|
# check that refresh works
|
||||||
session.get(f"{server}/{login_uri}")
|
session.get(login_uri)
|
||||||
userinfo = session.get(f"{server}/d/pbmc3k.cxg/api/v0.2/userinfo").json()
|
userinfo = session.get(f"{server}/d/pbmc3k.cxg/api/v0.2/userinfo").json()
|
||||||
self.assertTrue(userinfo["userinfo"]["is_authenticated"])
|
self.assertTrue(userinfo["userinfo"]["is_authenticated"])
|
||||||
self.assertEqual(userinfo["userinfo"]["username"], "fake_user")
|
self.assertEqual(userinfo["userinfo"]["username"], "fake_user")
|
||||||
@@ -127,12 +142,12 @@ class AuthTest(unittest.TestCase):
|
|||||||
cookie = session.cookies.get(cookie_key)
|
cookie = session.cookies.get(cookie_key)
|
||||||
token = json.loads(base64.b64decode(cookie))
|
token = json.loads(base64.b64decode(cookie))
|
||||||
access_token_after = token.get("access_token")
|
access_token_after = token.get("access_token")
|
||||||
expires_at_after = token.get("expires_at")
|
id_token_after = token.get("id_token")
|
||||||
|
|
||||||
self.assertNotEqual(access_token_before, access_token_after)
|
self.assertNotEqual(access_token_before, access_token_after)
|
||||||
self.assertTrue(expires_at_after - expires_at_before > TOKEN_EXPIRES)
|
self.assertNotEqual(id_token_before, id_token_after)
|
||||||
|
|
||||||
r = session.get(f"{server}/{logout_uri}")
|
r = session.get(logout_uri)
|
||||||
# check that the logout redirect worked
|
# check that the logout redirect worked
|
||||||
self.assertEqual(r.history[0].status_code, 302)
|
self.assertEqual(r.history[0].status_code, 302)
|
||||||
self.assertEqual(r.url, f"{server}")
|
self.assertEqual(r.url, f"{server}")
|
||||||
@@ -146,31 +161,16 @@ class AuthTest(unittest.TestCase):
|
|||||||
# test with session cookies
|
# test with session cookies
|
||||||
app_config = AppConfig()
|
app_config = AppConfig()
|
||||||
app_config.update_server_config(
|
app_config.update_server_config(
|
||||||
authentication__type="oauth",
|
|
||||||
authentication__params_oauth__api_base_url=f"http://localhost:{PORT}",
|
|
||||||
authentication__params_oauth__client_id="mock_client_id",
|
|
||||||
authentication__params_oauth__client_secret="mock_client_secret",
|
|
||||||
authentication__params_oauth__session_cookie=True,
|
authentication__params_oauth__session_cookie=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
app_config.update_server_config(multi_dataset__dataroot=self.dataset_dataroot)
|
|
||||||
app_config.complete_config()
|
|
||||||
|
|
||||||
self.auth_flow(app_config)
|
self.auth_flow(app_config)
|
||||||
|
|
||||||
def test_auth_oauth_cookie(self):
|
def test_auth_oauth_cookie(self):
|
||||||
# test with specified cookie
|
# test with specified cookie
|
||||||
app_config = AppConfig()
|
app_config = AppConfig()
|
||||||
app_config.update_server_config(
|
app_config.update_server_config(
|
||||||
authentication__type="oauth",
|
|
||||||
authentication__params_oauth__api_base_url=f"http://localhost:{PORT}",
|
|
||||||
authentication__params_oauth__client_id="mock_client_id",
|
|
||||||
authentication__params_oauth__client_secret="mock_client_secret",
|
|
||||||
authentication__params_oauth__session_cookie=False,
|
authentication__params_oauth__session_cookie=False,
|
||||||
authentication__params_oauth__cookie=dict(key="test_cxguser", httponly=True, max_age=60),
|
authentication__params_oauth__cookie=dict(key="test_cxguser", httponly=True, max_age=60),
|
||||||
)
|
)
|
||||||
|
|
||||||
app_config.update_server_config(multi_dataset__dataroot=self.dataset_dataroot)
|
|
||||||
app_config.complete_config()
|
|
||||||
|
|
||||||
self.auth_flow(app_config, "test_cxguser")
|
self.auth_flow(app_config, "test_cxguser")
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import os
|
|||||||
import unittest
|
import unittest
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
import tempfile
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from server.common.app_config import AppConfig
|
from server.common.app_config import AppConfig
|
||||||
from server.common.errors import ConfigurationError
|
from server.common.errors import ConfigurationError
|
||||||
|
from server.common.utils.utils import find_available_port
|
||||||
from server.test import PROJECT_ROOT, test_server, FIXTURES_ROOT
|
from server.test import PROJECT_ROOT, test_server, FIXTURES_ROOT
|
||||||
|
|
||||||
|
|
||||||
@@ -19,46 +21,46 @@ def mockenv(**envvars):
|
|||||||
|
|
||||||
class AppConfigTest(unittest.TestCase):
|
class AppConfigTest(unittest.TestCase):
|
||||||
def test_update(self):
|
def test_update(self):
|
||||||
c = AppConfig()
|
config = AppConfig()
|
||||||
c.update_server_config(app__verbose=True, multi_dataset__dataroot="datadir")
|
config.update_server_config(app__verbose=True, multi_dataset__dataroot="datadir")
|
||||||
v = c.server_config.changes_from_default()
|
vars = config.server_config.changes_from_default()
|
||||||
self.assertCountEqual(v, [("app__verbose", True, False), ("multi_dataset__dataroot", "datadir", None)])
|
self.assertCountEqual(vars, [("app__verbose", True, False), ("multi_dataset__dataroot", "datadir", None)])
|
||||||
|
|
||||||
c = AppConfig()
|
config = AppConfig()
|
||||||
c.update_default_dataset_config(app__scripts=(), app__inline_scripts=())
|
config.update_default_dataset_config(app__scripts=(), app__inline_scripts=())
|
||||||
v = c.server_config.changes_from_default()
|
vars = config.server_config.changes_from_default()
|
||||||
self.assertCountEqual(v, [])
|
self.assertCountEqual(vars, [])
|
||||||
|
|
||||||
c = AppConfig()
|
config = AppConfig()
|
||||||
c.update_default_dataset_config(app__scripts=[], app__inline_scripts=[])
|
config.update_default_dataset_config(app__scripts=[], app__inline_scripts=[])
|
||||||
v = c.default_dataset_config.changes_from_default()
|
vars = config.default_dataset_config.changes_from_default()
|
||||||
self.assertCountEqual(v, [])
|
self.assertCountEqual(vars, [])
|
||||||
|
|
||||||
c = AppConfig()
|
config = AppConfig()
|
||||||
c.update_default_dataset_config(app__scripts=("a", "b"), app__inline_scripts=["c", "d"])
|
config.update_default_dataset_config(app__scripts=("a", "b"), app__inline_scripts=["c", "d"])
|
||||||
v = c.default_dataset_config.changes_from_default()
|
vars = config.default_dataset_config.changes_from_default()
|
||||||
self.assertCountEqual(v, [("app__scripts", ["a", "b"], []), ("app__inline_scripts", ["c", "d"], [])])
|
self.assertCountEqual(vars, [("app__scripts", ["a", "b"], []), ("app__inline_scripts", ["c", "d"], [])])
|
||||||
|
|
||||||
def test_multi_dataset(self):
|
def test_multi_dataset(self):
|
||||||
|
|
||||||
c = AppConfig()
|
config = AppConfig()
|
||||||
# test for illegal url_dataroots
|
# test for illegal url_dataroots
|
||||||
for illegal in ("../b", "!$*", "\\n", "", "(bad)"):
|
for illegal in ("../b", "!$*", "\\n", "", "(bad)"):
|
||||||
c.update_server_config(
|
config.update_server_config(
|
||||||
multi_dataset__dataroot={"tag": {"base_url": illegal, "dataroot": "{PROJECT_ROOT}/example-dataset"}}
|
multi_dataset__dataroot={"tag": {"base_url": illegal, "dataroot": "{PROJECT_ROOT}/example-dataset"}}
|
||||||
)
|
)
|
||||||
with self.assertRaises(ConfigurationError):
|
with self.assertRaises(ConfigurationError):
|
||||||
c.complete_config()
|
config.complete_config()
|
||||||
|
|
||||||
# test for legal url_dataroots
|
# test for legal url_dataroots
|
||||||
for legal in ("d", "this.is-okay_", "a/b"):
|
for legal in ("d", "this.is-okay_", "a/b"):
|
||||||
c.update_server_config(
|
config.update_server_config(
|
||||||
multi_dataset__dataroot={"tag": {"base_url": legal, "dataroot": "{PROJECT_ROOT}/example-dataset"}}
|
multi_dataset__dataroot={"tag": {"base_url": legal, "dataroot": "{PROJECT_ROOT}/example-dataset"}}
|
||||||
)
|
)
|
||||||
c.complete_config()
|
config.complete_config()
|
||||||
|
|
||||||
# test that multi dataroots work end to end
|
# test that multi dataroots work end to end
|
||||||
c.update_server_config(
|
config.update_server_config(
|
||||||
multi_dataset__dataroot=dict(
|
multi_dataset__dataroot=dict(
|
||||||
s1=dict(dataroot=f"{PROJECT_ROOT}/example-dataset", base_url="set1/1/2"),
|
s1=dict(dataroot=f"{PROJECT_ROOT}/example-dataset", base_url="set1/1/2"),
|
||||||
s2=dict(dataroot=f"{FIXTURES_ROOT}", base_url="set2"),
|
s2=dict(dataroot=f"{FIXTURES_ROOT}", base_url="set2"),
|
||||||
@@ -67,46 +69,46 @@ class AppConfigTest(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Change this default to test if the dataroot overrides below work.
|
# Change this default to test if the dataroot overrides below work.
|
||||||
c.update_default_dataset_config(app__about_legal_tos="tos_default.html")
|
config.update_default_dataset_config(app__about_legal_tos="tos_default.html")
|
||||||
|
|
||||||
# specialize the configs for set1
|
# specialize the configs for set1
|
||||||
c.add_dataroot_config(
|
config.add_dataroot_config(
|
||||||
"s1", user_annotations__enable=False, diffexp__enable=True, app__about_legal_tos="tos_set1.html"
|
"s1", user_annotations__enable=False, diffexp__enable=True, app__about_legal_tos="tos_set1.html"
|
||||||
)
|
)
|
||||||
|
|
||||||
# specialize the configs for set2
|
# specialize the configs for set2
|
||||||
c.add_dataroot_config(
|
config.add_dataroot_config(
|
||||||
"s2", user_annotations__enable=True, diffexp__enable=False, app__about_legal_tos="tos_set2.html"
|
"s2", user_annotations__enable=True, diffexp__enable=False, app__about_legal_tos="tos_set2.html"
|
||||||
)
|
)
|
||||||
|
|
||||||
# no specializations for set3 (they get the default dataset config)
|
# no specializations for set3 (they get the default dataset config)
|
||||||
c.complete_config()
|
config.complete_config()
|
||||||
|
|
||||||
with test_server(app_config=c) as server:
|
with test_server(app_config=config) as server:
|
||||||
session = requests.Session()
|
session = requests.Session()
|
||||||
|
|
||||||
r = session.get(f"{server}/set1/1/2/pbmc3k.h5ad/api/v0.2/config")
|
response = session.get(f"{server}/set1/1/2/pbmc3k.h5ad/api/v0.2/config")
|
||||||
data_config = r.json()
|
data_config = response.json()
|
||||||
assert data_config["config"]["displayNames"]["dataset"] == "pbmc3k"
|
assert data_config["config"]["displayNames"]["dataset"] == "pbmc3k"
|
||||||
assert data_config["config"]["parameters"]["annotations"] is False
|
assert data_config["config"]["parameters"]["annotations"] is False
|
||||||
assert data_config["config"]["parameters"]["disable-diffexp"] is False
|
assert data_config["config"]["parameters"]["disable-diffexp"] is False
|
||||||
assert data_config["config"]["parameters"]["about_legal_tos"] == "tos_set1.html"
|
assert data_config["config"]["parameters"]["about_legal_tos"] == "tos_set1.html"
|
||||||
|
|
||||||
r = session.get(f"{server}/set2/pbmc3k.cxg/api/v0.2/config")
|
response = session.get(f"{server}/set2/pbmc3k.cxg/api/v0.2/config")
|
||||||
data_config = r.json()
|
data_config = response.json()
|
||||||
assert data_config["config"]["displayNames"]["dataset"] == "pbmc3k"
|
assert data_config["config"]["displayNames"]["dataset"] == "pbmc3k"
|
||||||
assert data_config["config"]["parameters"]["annotations"] is True
|
assert data_config["config"]["parameters"]["annotations"] is True
|
||||||
assert data_config["config"]["parameters"]["about_legal_tos"] == "tos_set2.html"
|
assert data_config["config"]["parameters"]["about_legal_tos"] == "tos_set2.html"
|
||||||
|
|
||||||
r = session.get(f"{server}/set3/pbmc3k.cxg/api/v0.2/config")
|
response = session.get(f"{server}/set3/pbmc3k.cxg/api/v0.2/config")
|
||||||
data_config = r.json()
|
data_config = response.json()
|
||||||
assert data_config["config"]["displayNames"]["dataset"] == "pbmc3k"
|
assert data_config["config"]["displayNames"]["dataset"] == "pbmc3k"
|
||||||
assert data_config["config"]["parameters"]["annotations"] is True
|
assert data_config["config"]["parameters"]["annotations"] is True
|
||||||
assert data_config["config"]["parameters"]["disable-diffexp"] is False
|
assert data_config["config"]["parameters"]["disable-diffexp"] is False
|
||||||
assert data_config["config"]["parameters"]["about_legal_tos"] == "tos_default.html"
|
assert data_config["config"]["parameters"]["about_legal_tos"] == "tos_default.html"
|
||||||
|
|
||||||
r = session.get(f"{server}/health")
|
response = session.get(f"{server}/health")
|
||||||
assert r.json()["status"] == "pass"
|
assert response.json()["status"] == "pass"
|
||||||
|
|
||||||
@mockenv(CXG_AWS_SECRET_NAME="TESTING", CXG_AWS_SECRET_REGION_NAME="TEST_REGION")
|
@mockenv(CXG_AWS_SECRET_NAME="TESTING", CXG_AWS_SECRET_REGION_NAME="TEST_REGION")
|
||||||
@patch('server.common.aws_secret_utils.get_secret_key')
|
@patch('server.common.aws_secret_utils.get_secret_key')
|
||||||
@@ -133,3 +135,64 @@ class AppConfigTest(unittest.TestCase):
|
|||||||
self.assertEqual(config.server_config.app__flask_secret_key, "mock_flask_secret")
|
self.assertEqual(config.server_config.app__flask_secret_key, "mock_flask_secret")
|
||||||
self.assertEqual(config.server_config.authentication__params_oauth__client_secret, "mock_oauth_secret")
|
self.assertEqual(config.server_config.authentication__params_oauth__client_secret, "mock_oauth_secret")
|
||||||
self.assertEqual(config.default_dataset_config.user_annotations__hosted_tiledb_array__db_uri, "mock_db_uri")
|
self.assertEqual(config.default_dataset_config.user_annotations__hosted_tiledb_array__db_uri, "mock_db_uri")
|
||||||
|
|
||||||
|
def test_api_base_url(self):
|
||||||
|
|
||||||
|
# test the api_base_url feature, and that it can contain a path
|
||||||
|
config = AppConfig()
|
||||||
|
backend_port = find_available_port("localhost", 10000)
|
||||||
|
config.update_server_config(
|
||||||
|
app__api_base_url=f"http://localhost:{backend_port}/additional/path/before/dataroot",
|
||||||
|
multi_dataset__dataroot=f"{PROJECT_ROOT}/example-dataset"
|
||||||
|
)
|
||||||
|
|
||||||
|
config.complete_config()
|
||||||
|
|
||||||
|
with test_server(["-p", str(backend_port)], app_config=config) as server:
|
||||||
|
session = requests.Session()
|
||||||
|
self.assertEqual(server, f"http://localhost:{backend_port}")
|
||||||
|
response = session.get(f"{server}/additional/path/before/dataroot/d/pbmc3k.h5ad/api/v0.2/config")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
data_config = response.json()
|
||||||
|
self.assertEqual(data_config["config"]["displayNames"]["dataset"], "pbmc3k")
|
||||||
|
|
||||||
|
def test_configfile_with_specialization(self):
|
||||||
|
# test that per_dataset_config config load the default config, then the specialized config
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tempdir:
|
||||||
|
configfile = os.path.join(tempdir, "config.yaml")
|
||||||
|
with open(configfile, "w") as fconfig:
|
||||||
|
config = """
|
||||||
|
server:
|
||||||
|
multi_dataset:
|
||||||
|
dataroot:
|
||||||
|
test:
|
||||||
|
base_url: test
|
||||||
|
dataroot: fake_dataroot
|
||||||
|
|
||||||
|
dataset:
|
||||||
|
user_annotations:
|
||||||
|
enable: false
|
||||||
|
type: hosted_tiledb_array
|
||||||
|
hosted_tiledb_array:
|
||||||
|
db_uri: fake_db_uri
|
||||||
|
hosted_file_directory: fake_dir
|
||||||
|
|
||||||
|
per_dataset_config:
|
||||||
|
test:
|
||||||
|
user_annotations:
|
||||||
|
enable: true
|
||||||
|
"""
|
||||||
|
fconfig.write(config)
|
||||||
|
|
||||||
|
app_config = AppConfig()
|
||||||
|
app_config.update_from_config_file(configfile)
|
||||||
|
|
||||||
|
test_config = app_config.dataroot_config["test"]
|
||||||
|
|
||||||
|
# test config from default
|
||||||
|
self.assertEqual(test_config.user_annotations__type, "hosted_tiledb_array")
|
||||||
|
self.assertEqual(test_config.user_annotations__hosted_tiledb_array__db_uri, "fake_db_uri")
|
||||||
|
|
||||||
|
# test config from specialization
|
||||||
|
self.assertTrue(test_config.user_annotations__enable)
|
||||||
|
|||||||
Reference in New Issue
Block a user