Merge branch 'master' into colinmegill/geneset-prototype

This commit is contained in:
Colin Megill
2020-05-19 15:58:36 -04:00
47 changed files with 314 additions and 294 deletions
+3 -3
View File
@@ -1,9 +1,9 @@
export const jest_env = process.env.JEST_ENV; export const jestEnv = process.env.JEST_ENV;
export const appPort = process.env.CXG_SERVER_PORT; export const appPort = process.env.CXG_SERVER_PORT;
export const appUrlBase = export const appUrlBase =
process.env.CXG_URL_BASE || `http://localhost:${appPort}`; process.env.CXG_URL_BASE || `http://localhost:${appPort}`;
export const DEV = jest_env === "dev"; export const DEV = jestEnv === "dev";
export const DEBUG = jest_env === "debug"; export const DEBUG = jestEnv === "debug";
export const DATASET = "pbmc3k"; export const DATASET = "pbmc3k";
if (DEBUG) jest.setTimeout(100000); if (DEBUG) jest.setTimeout(100000);
+17 -3
View File
@@ -7,7 +7,10 @@ import { appUrlBase, DATASET } from "./config";
import { setupTestBrowser } from "./testBrowser"; import { setupTestBrowser } from "./testBrowser";
import { datasets } from "./data"; import { datasets } from "./data";
let browser, page, utils, cxgActions; let browser;
let page;
let utils;
let cxgActions;
const data = datasets[DATASET]; const data = datasets[DATASET];
beforeAll(async () => { beforeAll(async () => {
@@ -29,6 +32,17 @@ describe("did launch", () => {
); );
expect(element).toBe(data.title); expect(element).toBe(data.title);
}); });
test("terms of service, if they are there", async () => {
try {
await utils.clickOn("tos-cookies-accept", { timeout: 500 });
} catch {
console.warn("No terms of service footer detected.");
}
page.waitFor(50); // give the footer a chance to disappear
const result = await page.$("[data-testid='tos-cookies-accept']");
expect(result).toBeNull();
});
}); });
describe("metadata loads", () => { describe("metadata loads", () => {
@@ -119,7 +133,7 @@ describe("gene entry", () => {
testGenes testGenes
); );
expect(allHistograms).toEqual(expect.arrayContaining(testGenes)); expect(allHistograms).toEqual(expect.arrayContaining(testGenes));
expect(allHistograms.length).toEqual(testGenes.length); expect(allHistograms).toHaveLength(testGenes.length);
}); });
}); });
@@ -133,7 +147,7 @@ describe("differential expression", () => {
expect(allHistograms).toEqual( expect(allHistograms).toEqual(
expect.arrayContaining(data.diffexp["gene-results"]) expect.arrayContaining(data.diffexp["gene-results"])
); );
expect(allHistograms.length).toEqual(data.diffexp["gene-results"].length); expect(allHistograms).toHaveLength(data.diffexp["gene-results"].length);
}); });
}); });
+4 -1
View File
@@ -5,7 +5,10 @@ import { appUrlBase, DATASET } from "./config";
import { setupTestBrowser } from "./testBrowser"; import { setupTestBrowser } from "./testBrowser";
import { datasets } from "./data"; import { datasets } from "./data";
let browser, page, utils, actions; let browser;
let page;
let utils;
let actions;
const data = datasets[DATASET]; const data = datasets[DATASET];
beforeAll(async () => { beforeAll(async () => {
+6 -2
View File
@@ -12,9 +12,13 @@ import { appUrlBase, DEBUG, DEV, DATASET } from "./config";
import { puppeteerUtils, cellxgeneActions } from "./puppeteerUtils"; import { puppeteerUtils, cellxgeneActions } from "./puppeteerUtils";
import { datasets } from "./data"; import { datasets } from "./data";
let browser, page, utils, cxgActions, spy; let browser;
let page;
let utils;
let cxgActions;
let spy;
const browserViewport = { width: 1280, height: 960 }; const browserViewport = { width: 1280, height: 960 };
let data = datasets[DATASET].features; const data = datasets[DATASET].features;
if (DEBUG) jest.setTimeout(100000); if (DEBUG) jest.setTimeout(100000);
if (DEV) jest.setTimeout(10000); if (DEV) jest.setTimeout(10000);
+2 -2
View File
@@ -44,8 +44,8 @@ export const puppeteerUtils = (page) => ({
}, },
async clickOn(testid, options = {}) { async clickOn(testid, options = {}) {
await this.waitByID(testid); await this.waitByID(testid, options);
const click = await page.click(`[data-testid='${testid}']`, options); const click = await page.click(`[data-testid='${testid}']`);
await page.waitFor(50); await page.waitFor(50);
return click; return click;
}, },
+4
View File
@@ -36,6 +36,10 @@ export async function setupTestBrowser() {
page.on("console", async (msg) => { page.on("console", async (msg) => {
// If there is a console.error but an error is not thrown, this will ensure the test fails // If there is a console.error but an error is not thrown, this will ensure the test fails
if (msg.type() === "error") { if (msg.type() === "error") {
// TODO: chromium does not currently support the CSP directive on the
// line below, so we swallow this error. Remove this when the test
// suite uses a browser version that supports this directive.
if (msg.text() === "Unrecognized Content-Security-Policy directive 'require-trusted-types-for'.\n") return;
const errorMsgText = await Promise.all( const errorMsgText = await Promise.all(
// TODO can we do this without internal properties? // TODO can we do this without internal properties?
msg.args().map((arg) => arg._remoteObject.description) msg.args().map((arg) => arg._remoteObject.description)
+1 -1
View File
@@ -1,4 +1,4 @@
import { PromiseLimit } from "../../src/util/promiseLimit"; import PromiseLimit from "../../src/util/promiseLimit";
import { range } from "../../src/util/range"; import { range } from "../../src/util/range";
const delay = (t) => new Promise((resolve, reject) => setTimeout(resolve, t)); const delay = (t) => new Promise((resolve, reject) => setTimeout(resolve, t));
@@ -54,7 +54,7 @@ class CspHashPlugin {
.createHash("sha256") .createHash("sha256")
.update(str, "utf8") .update(str, "utf8")
.digest("base64"); .digest("base64");
return "sha256-" + hash; return `sha256-${hash}`;
} }
} }
+1 -1
View File
@@ -7,7 +7,7 @@ import {
doBinaryRequest, doBinaryRequest,
dispatchNetworkErrorMessageToUser, dispatchNetworkErrorMessageToUser,
} from "../util/actionHelpers"; } from "../util/actionHelpers";
import { PromiseLimit } from "../util/promiseLimit"; import PromiseLimit from "../util/promiseLimit";
import { requestReembed, reembedResetWorldToUniverse } from "./reembed"; import { requestReembed, reembedResetWorldToUniverse } from "./reembed";
import { loadUserColorConfig } from "../util/stateManager/colorHelpers"; import { loadUserColorConfig } from "../util/stateManager/colorHelpers";
@@ -18,7 +18,7 @@ import {
saveInProgress: state.autosave?.saveInProgress ?? false, saveInProgress: state.autosave?.saveInProgress ?? false,
lastSavedObsAnnotations: state.autosave?.lastSavedObsAnnotations, lastSavedObsAnnotations: state.autosave?.lastSavedObsAnnotations,
error: state.autosave?.error, error: state.autosave?.error,
writableCategoriesEnabled: state.config?.parameters?.["annotations"] ?? false, writableCategoriesEnabled: state.config?.parameters?.annotations ?? false,
})) }))
class FilenameDialog extends React.Component { class FilenameDialog extends React.Component {
constructor(props) { constructor(props) {
+1 -2
View File
@@ -1,6 +1,5 @@
import React from "react"; import React from "react";
import { connect } from "react-redux"; import { connect } from "react-redux";
import * as globals from "../../globals";
import actions from "../../actions"; import actions from "../../actions";
import FilenameDialog from "./filenameDialog"; import FilenameDialog from "./filenameDialog";
@@ -11,7 +10,7 @@ import FilenameDialog from "./filenameDialog";
saveInProgress: state.autosave?.saveInProgress ?? false, saveInProgress: state.autosave?.saveInProgress ?? false,
lastSavedObsAnnotations: state.autosave?.lastSavedObsAnnotations, lastSavedObsAnnotations: state.autosave?.lastSavedObsAnnotations,
error: state.autosave?.error, error: state.autosave?.error,
writableCategoriesEnabled: state.config?.parameters?.["annotations"] ?? false, writableCategoriesEnabled: state.config?.parameters?.annotations ?? false,
initialDataLoadComplete: state.autosave?.initialDataLoadComplete, initialDataLoadComplete: state.autosave?.initialDataLoadComplete,
})) }))
class Autosave extends React.Component { class Autosave extends React.Component {
@@ -64,7 +64,7 @@ class Category extends React.Component {
} else if (categoryCount.selectedCatCount < categoryCount.totalCatCount) { } else if (categoryCount.selectedCatCount < categoryCount.totalCatCount) {
/* to be explicit... */ /* to be explicit... */
this.checkbox.indeterminate = true; this.checkbox.indeterminate = true;
this.setState({ isChecked: false }); this.setState({ isChecked: false }); // eslint-disable-line react/no-did-update-set-state
} }
} }
} }
@@ -77,14 +77,15 @@ class Category extends React.Component {
}); });
}; };
toggleAll() { handleCategoryClick = () => {
const { dispatch, metadataField } = this.props; const { annotations, metadataField, onExpansionChange } = this.props;
dispatch({ const editingCategory =
type: "categorical metadata filter all of these", annotations.isEditingCategoryName &&
metadataField, annotations.categoryBeingEdited === metadataField;
}); if (!editingCategory) {
this.setState({ isChecked: true }); onExpansionChange(metadataField);
} }
};
toggleNone() { toggleNone() {
const { dispatch, metadataField } = this.props; const { dispatch, metadataField } = this.props;
@@ -95,6 +96,15 @@ class Category extends React.Component {
this.setState({ isChecked: false }); this.setState({ isChecked: false });
} }
toggleAll() {
const { dispatch, metadataField } = this.props;
dispatch({
type: "categorical metadata filter all of these",
metadataField,
});
this.setState({ isChecked: true });
}
handleToggleAllClick() { handleToggleAllClick() {
const { isChecked } = this.state; const { isChecked } = this.state;
// || this.checkbox.indeterminate === false // || this.checkbox.indeterminate === false
@@ -115,6 +125,8 @@ class Category extends React.Component {
globals.categoryDisplayStringMaxLength globals.categoryDisplayStringMaxLength
); );
const checkboxID = `category-select-${metadataField}`;
return ( return (
<div <div
style={{ style={{
@@ -135,8 +147,8 @@ class Category extends React.Component {
alignItems: "flex-start", alignItems: "flex-start",
}} }}
> >
<label className="bp3-control bp3-checkbox"> <label htmlFor={checkboxID} className="bp3-control bp3-checkbox">
<input disabled checked type="checkbox" /> <input disabled id={checkboxID} checked type="checkbox" />
<span className="bp3-control-indicator" /> <span className="bp3-control-indicator" />
</label> </label>
<Tooltip <Tooltip
@@ -174,9 +186,7 @@ class Category extends React.Component {
metadataField, metadataField,
categoricalSelection, categoricalSelection,
isColorAccessor, isColorAccessor,
annotations,
isExpanded, isExpanded,
onExpansionChange,
schema, schema,
} = this.props; } = this.props;
@@ -185,6 +195,8 @@ class Category extends React.Component {
return this.renderIsStillLoading(); return this.renderIsStillLoading();
} }
const checkboxID = `category-select-${metadataField}`;
const isUserAnno = const isUserAnno =
schema?.annotations?.obsByName[metadataField]?.writable ?? false; schema?.annotations?.obsByName[metadataField]?.writable ?? false;
const isTruncated = _.get( const isTruncated = _.get(
@@ -225,8 +237,9 @@ class Category extends React.Component {
alignItems: "flex-start", alignItems: "flex-start",
}} }}
> >
<label className="bp3-control bp3-checkbox"> <label className="bp3-control bp3-checkbox" htmlFor={checkboxID}>
<input <input
id={checkboxID}
data-testclass="category-select" data-testclass="category-select"
data-testid={`${metadataField}:category-select`} data-testid={`${metadataField}:category-select`}
onChange={this.handleToggleAllClick.bind(this)} onChange={this.handleToggleAllClick.bind(this)}
@@ -251,19 +264,19 @@ class Category extends React.Component {
}} }}
> >
<span <span
role="menuitem"
tabIndex="0"
data-testid={`${metadataField}:category-expand`} data-testid={`${metadataField}:category-expand`}
onKeyPress={(e) => {
if (e.key === "Enter") {
this.handleCategoryClick();
}
}}
style={{ style={{
cursor: "pointer", cursor: "pointer",
display: "inline-block", display: "inline-block",
}} }}
onClick={() => { onClick={this.handleCategoryClick}
const editingCategory =
annotations.isEditingCategoryName &&
annotations.categoryBeingEdited === metadataField;
if (!editingCategory) {
onExpansionChange(metadataField);
}
}}
> >
{isUserAnno ? ( {isUserAnno ? (
<Icon style={{ marginRight: 5 }} icon="tag" iconSize={16} /> <Icon style={{ marginRight: 5 }} icon="tag" iconSize={16} />
+1 -1
View File
@@ -11,7 +11,7 @@ import LabelInput from "./labelInput";
import { labelPrompt } from "./labelUtil"; import { labelPrompt } from "./labelUtil";
@connect((state) => ({ @connect((state) => ({
writableCategoriesEnabled: state.config?.parameters?.["annotations"] ?? false, writableCategoriesEnabled: state.config?.parameters?.annotations ?? false,
schema: state.world?.schema, schema: state.world?.schema,
config: state.config, config: state.config,
ontology: state.ontology, ontology: state.ontology,
@@ -23,6 +23,9 @@ export default class LabelInput extends React.PureComponent {
* popoverProps -- will be passed to <Suggest> * popoverProps -- will be passed to <Suggest>
*/ */
/* maxinum number of suggestions */
static QueryResultLimit = 100;
constructor(props) { constructor(props) {
super(props); super(props);
@@ -103,9 +106,6 @@ export default class LabelInput extends React.PureComponent {
); );
}; };
/* maxinum number of suggestions */
static QueryResultLimit = 100;
filterLabels(query) { filterLabels(query) {
const { labelSuggestions } = this.props; const { labelSuggestions } = this.props;
if (!labelSuggestions) return []; if (!labelSuggestions) return [];
@@ -62,6 +62,8 @@ class CategoryValue extends React.Component {
prevProps.metadataField !== metadataField || prevProps.metadataField !== metadataField ||
prevProps.categoryIndex !== categoryIndex prevProps.categoryIndex !== categoryIndex
) { ) {
// adequately checked to prevent looping
// eslint-disable-next-line react/no-did-update-set-state
this.setState({ this.setState({
editedLabelText: this.currentLabel(), editedLabelText: this.currentLabel(),
}); });
@@ -327,6 +329,8 @@ class CategoryValue extends React.Component {
annotations.isEditingLabelName && annotations.isEditingLabelName &&
annotations.labelEditable.label === categoryIndex; annotations.labelEditable.label === categoryIndex;
const valueToggleLabel = `value-toggle-checkbox-${displayString}`;
return ( return (
<div <div
key={i} key={i}
@@ -360,8 +364,13 @@ class CategoryValue extends React.Component {
}} }}
> >
<div style={{ display: "flex", alignItems: "baseline" }}> <div style={{ display: "flex", alignItems: "baseline" }}>
<label className="bp3-control bp3-checkbox" style={{ margin: 0 }}> <label
htmlFor={valueToggleLabel}
className="bp3-control bp3-checkbox"
style={{ margin: 0 }}
>
<input <input
id={valueToggleLabel}
onChange={selected ? this.toggleOff : this.toggleOn} onChange={selected ? this.toggleOff : this.toggleOn}
data-testclass="categorical-value-select" data-testclass="categorical-value-select"
data-testid={`categorical-value-select-${metadataField}-${displayString}`} data-testid={`categorical-value-select-${metadataField}-${displayString}`}
@@ -7,11 +7,6 @@ const ToastTopCenter = Toaster.create({
position: Position.TOP, position: Position.TOP,
}); });
const ToastBottomCenter = Toaster.create({
className: "recipe-toaster",
position: Position.BOTTOM,
});
/* /*
A "user" error - eg, bad input A "user" error - eg, bad input
*/ */
@@ -262,7 +262,7 @@ class AddGenes extends React.Component {
}} }}
itemListPredicate={filterGenes} itemListPredicate={filterGenes}
onActiveItemChange={(item) => this.setState({ activeItem: item })} onActiveItemChange={(item) => this.setState({ activeItem: item })}
itemRenderer={renderGene.bind(this)} itemRenderer={renderGene}
items={varIndex || ["No genes"]} items={varIndex || ["No genes"]}
popoverProps={{ minimal: true }} popoverProps={{ minimal: true }}
/> />
+30 -23
View File
@@ -10,7 +10,6 @@ import setupSVGandBrushElements from "./setupSVGandBrush";
import _camera from "../../util/camera"; import _camera from "../../util/camera";
import _drawPoints from "./drawPointsRegl"; import _drawPoints from "./drawPointsRegl";
import { isTypedArray } from "../../util/typeHelpers"; import { isTypedArray } from "../../util/typeHelpers";
import styles from "./graph.css";
import GraphOverlayLayer from "./overlays/graphOverlayLayer"; import GraphOverlayLayer from "./overlays/graphOverlayLayer";
import CentroidLabels from "./overlays/centroidLabels"; import CentroidLabels from "./overlays/centroidLabels";
@@ -190,7 +189,7 @@ class Graph extends React.Component {
tool: null, tool: null,
container: null, container: null,
cameraRender: 0, cameraRender: 0,
viewport viewport,
}; };
} }
@@ -209,7 +208,10 @@ class Graph extends React.Component {
// create all default rendering transformations // create all default rendering transformations
const modelTF = createModelTF(); const modelTF = createModelTF();
const projectionTF = createProjectionTF(this.reglCanvas.width, this.reglCanvas.height); const projectionTF = createProjectionTF(
this.reglCanvas.width,
this.reglCanvas.height
);
// initial draw to canvas // initial draw to canvas
this.renderPoints( this.renderPoints(
@@ -250,7 +252,8 @@ class Graph extends React.Component {
} = this.props; } = this.props;
const { regl, toolSVG, camera, modelTF, viewport } = this.state; const { regl, toolSVG, camera, modelTF, viewport } = this.state;
let { projectionTF } = this.state; let { projectionTF } = this.state;
const hasResized = prevState.viewport.height !== this.reglCanvas.height || const hasResized =
prevState.viewport.height !== this.reglCanvas.height ||
prevState.viewport.width !== this.reglCanvas.width; prevState.viewport.width !== this.reglCanvas.width;
let stateChanges = {}; let stateChanges = {};
let needsRepaint = hasResized; let needsRepaint = hasResized;
@@ -261,7 +264,10 @@ class Graph extends React.Component {
const { drawPoints, pointBuffer, colorBuffer, flagBuffer } = this.state; const { drawPoints, pointBuffer, colorBuffer, flagBuffer } = this.state;
if (hasResized) { if (hasResized) {
projectionTF = createProjectionTF(this.reglCanvas.width, this.reglCanvas.height); projectionTF = createProjectionTF(
this.reglCanvas.width,
this.reglCanvas.height
);
stateChanges = { stateChanges = {
...stateChanges, ...stateChanges,
projectionTF, projectionTF,
@@ -322,7 +328,10 @@ class Graph extends React.Component {
...stateChanges, ...stateChanges,
...this.createToolSVG(), ...this.createToolSVG(),
}; };
} else if ((viewport.height && viewport.width && !toolSVG) || selectionTool !== prevProps.selectionTool ) { } else if (
(viewport.height && viewport.width && !toolSVG) ||
selectionTool !== prevProps.selectionTool
) {
// first time or change of selection tool // first time or change of selection tool
stateChanges = { ...stateChanges, ...this.createToolSVG() }; stateChanges = { ...stateChanges, ...this.createToolSVG() };
} else if (prevProps.graphInteractionMode !== graphInteractionMode) { } else if (prevProps.graphInteractionMode !== graphInteractionMode) {
@@ -349,6 +358,7 @@ class Graph extends React.Component {
); );
} }
if (Object.keys(stateChanges).length > 0) { if (Object.keys(stateChanges).length > 0) {
// Preventing update loop via stateChanges and diff checks
// eslint-disable-next-line react/no-did-update-set-state // eslint-disable-next-line react/no-did-update-set-state
this.setState(stateChanges); this.setState(stateChanges);
} }
@@ -371,12 +381,11 @@ class Graph extends React.Component {
const { viewportRef } = this.props; const { viewportRef } = this.props;
return { return {
height: viewportRef.clientHeight, height: viewportRef.clientHeight,
width: viewportRef.clientWidth width: viewportRef.clientWidth,
}; };
}; };
handleCanvasEvent = (e) => {
handleCanvasEvent = e => {
const { camera, projectionTF } = this.state; const { camera, projectionTF } = this.state;
if (e.type !== "wheel") e.preventDefault(); if (e.type !== "wheel") e.preventDefault();
if (camera.handleEvent(e, projectionTF)) { if (camera.handleEvent(e, projectionTF)) {
@@ -547,12 +556,8 @@ class Graph extends React.Component {
vec2.transformMat3(xy, xy, projectionTF); vec2.transformMat3(xy, xy, projectionTF);
return [ return [
Math.round( Math.round(((xy[0] + 1) * viewport.width) / 2),
(xy[0] + 1) * viewport.width / 2 Math.round(-((xy[1] + 1) / 2 - 1) * viewport.height),
),
Math.round(
-((xy[1] + 1) / 2 - 1) * viewport.height
)
]; ];
} }
@@ -698,7 +703,7 @@ class Graph extends React.Component {
count: this.count, count: this.count,
projView, projView,
nPoints: universe.nObs, nPoints: universe.nObs,
minViewportDimension: Math.min(width, height) minViewportDimension: Math.min(width, height),
}); });
regl._gl.flush(); regl._gl.flush();
} }
@@ -744,9 +749,11 @@ class Graph extends React.Component {
cameraTF={cameraTF} cameraTF={cameraTF}
modelTF={modelTF} modelTF={modelTF}
projectionTF={projectionTF} projectionTF={projectionTF}
handleCanvasEvent={graphInteractionMode === "zoom" ? this.handleCanvasEvent : undefined} handleCanvasEvent={
graphInteractionMode === "zoom" ? this.handleCanvasEvent : undefined
}
> >
<CentroidLabels/> <CentroidLabels />
</GraphOverlayLayer> </GraphOverlayLayer>
<svg <svg
id="lasso-layer" id="lasso-layer"
@@ -756,13 +763,11 @@ class Graph extends React.Component {
position: "absolute", position: "absolute",
top: 0, top: 0,
left: 0, left: 0,
zIndex: 1 zIndex: 1,
}} }}
width={viewport.width} width={viewport.width}
height={viewport.height} height={viewport.height}
pointerEvents={ pointerEvents={graphInteractionMode === "select" ? "auto" : "none"}
graphInteractionMode === "select" ? "auto" : "none"
}
/> />
<canvas <canvas
width={viewport.width} width={viewport.width}
@@ -777,7 +782,9 @@ class Graph extends React.Component {
}} }}
className="graph-canvas" className="graph-canvas"
data-testid="layout-graph" data-testid="layout-graph"
ref={canvas => { this.reglCanvas = canvas; }} ref={(canvas) => {
this.reglCanvas = canvas;
}}
onMouseDown={this.handleCanvasEvent} onMouseDown={this.handleCanvasEvent}
onMouseUp={this.handleCanvasEvent} onMouseUp={this.handleCanvasEvent}
onMouseMove={this.handleCanvasEvent} onMouseMove={this.handleCanvasEvent}
@@ -1,5 +1,3 @@
/* eslint-disable max-classes-per-file */
/* eslint-disable jsx-a11y/mouse-events-have-key-events */
import React, { PureComponent } from "react"; import React, { PureComponent } from "react";
import { connect } from "react-redux"; import { connect } from "react-redux";
@@ -26,7 +24,7 @@ class CentroidLabels extends PureComponent {
// Notify overlay layer of display change // Notify overlay layer of display change
overlayToggled("centroidLabels", displayChangeOn); overlayToggled("centroidLabels", displayChangeOn);
} }
}; }
render() { render() {
const { const {
@@ -59,6 +57,7 @@ class CentroidLabels extends PureComponent {
labelSVGS.push( labelSVGS.push(
<g <g
// label is unique so disabling eslint rule
// eslint-disable-next-line react/no-array-index-key // eslint-disable-next-line react/no-array-index-key
key={label} key={label}
className="centroid-label" className="centroid-label"
@@ -66,6 +65,8 @@ class CentroidLabels extends PureComponent {
data-testclass="centroid-label" data-testclass="centroid-label"
data-testid={`${label}-centroid-label`} data-testid={`${label}-centroid-label`}
> >
{/* The mouse actions for centroid labels do not have a screen reader alternative */}
{/* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */}
<text <text
transform={inverseTransform} transform={inverseTransform}
textAnchor="middle" textAnchor="middle"
@@ -2,8 +2,7 @@ import React, { PureComponent, cloneElement } from "react";
import styles from "../graph.css"; import styles from "../graph.css";
export default export default class GraphOverlayLayer extends PureComponent {
class GraphOverlayLayer extends PureComponent {
/* /*
This component takes its children (assumed in the data coordinate space ([0, 1] range, origin in bottom left corner)) This component takes its children (assumed in the data coordinate space ([0, 1] range, origin in bottom left corner))
and transforms itself multiple times resulting in screen space ([0, screenWidth/Height] range, origin in top left corner) and transforms itself multiple times resulting in screen space ([0, screenWidth/Height] range, origin in top left corner)
@@ -49,7 +48,6 @@ class GraphOverlayLayer extends PureComponent {
handleCanvasEvent, handleCanvasEvent,
width, width,
height, height,
style
} = this.props; } = this.props;
const { display } = this.state; const { display } = this.state;
@@ -63,7 +61,7 @@ class GraphOverlayLayer extends PureComponent {
cameraTF cameraTF
)} ${this.reverseMatrixScaleTransformString( )} ${this.reverseMatrixScaleTransformString(
projectionTF projectionTF
)} scale(1 2) scale(1 ${1 / (-height)}) scale(2 1) scale(${1 / width} 1)`; )} scale(1 2) scale(1 ${1 / -height}) scale(2 1) scale(${1 / width} 1)`;
// Copy the children passed with the overlay and add the inverse transform and onDisplayChange props // Copy the children passed with the overlay and add the inverse transform and onDisplayChange props
const newChildren = React.Children.map(children, (child) => const newChildren = React.Children.map(children, (child) =>
@@ -4,7 +4,7 @@ import { connect } from "react-redux";
import * as globals from "../../globals"; import * as globals from "../../globals";
import Logo from "../framework/logo"; import Logo from "../framework/logo";
@connect(state => ({ @connect((state) => ({
datasetTitle: state.config?.displayNames?.dataset ?? "", datasetTitle: state.config?.displayNames?.dataset ?? "",
aboutURL: state.config?.links?.["about-dataset"], aboutURL: state.config?.links?.["about-dataset"],
scatterplotXXaccessor: state.controls.scatterplotXXaccessor, scatterplotXXaccessor: state.controls.scatterplotXXaccessor,
@@ -75,7 +75,7 @@ class LeftSideBar extends React.Component {
title={datasetTitle} title={datasetTitle}
> >
{aboutURL ? ( {aboutURL ? (
<a href={aboutURL} target="_blank"> <a href={aboutURL} target="_blank" rel="noopener noreferrer">
{displayTitle} {displayTitle}
</a> </a>
) : ( ) : (
@@ -45,7 +45,9 @@ class CellSetButton extends React.PureComponent {
<AnchorButton <AnchorButton
type="button" type="button"
disabled={differential.diffExp} disabled={differential.diffExp}
onClick={this.set.bind(this)} onClick={() => {
this.set();
}}
data-testid={`cellset-button-${eitherCellSetOneOrTwo}`} data-testid={`cellset-button-${eitherCellSetOneOrTwo}`}
> >
{eitherCellSetOneOrTwo} {eitherCellSetOneOrTwo}
@@ -1,14 +1,7 @@
// 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 { import { Button, ButtonGroup, AnchorButton, Tooltip } from "@blueprintjs/core";
Popover,
Button,
ButtonGroup,
AnchorButton,
Tooltip,
Position,
} from "@blueprintjs/core";
import * as globals from "../../globals"; import * as globals from "../../globals";
import styles from "./menubar.css"; import styles from "./menubar.css";
import actions from "../../actions"; import actions from "../../actions";
@@ -21,16 +14,9 @@ import CellSetButton from "./cellSetButtons";
celllist1: state.differential?.celllist1, celllist1: state.differential?.celllist1,
celllist2: state.differential?.celllist2, celllist2: state.differential?.celllist2,
diffexpMayBeSlow: state.config?.parameters?.["diffexp-may-be-slow"] ?? false, diffexpMayBeSlow: state.config?.parameters?.["diffexp-may-be-slow"] ?? false,
diffexpCellcountMax: state.config?.limits?.diffexp_cellcount_max, diffexpCellcountMax: state.config?.limits?.["diffexp_cellcount_max"],
})) }))
class DiffexpButtons extends React.Component { class DiffexpButtons extends React.Component {
constructor(props) {
super(props);
this.state = {
userDismissedPopover: false,
};
}
computeDiffExp = () => { computeDiffExp = () => {
const { dispatch, differential } = this.props; const { dispatch, differential } = this.props;
if (differential.celllist1 && differential.celllist2) { if (differential.celllist1 && differential.celllist2) {
@@ -54,16 +40,9 @@ class DiffexpButtons extends React.Component {
}); });
}; };
handlePopoverDismiss = () => {
this.setState({
userDismissedPopover: true,
});
};
render() { render() {
/* diffexp-related buttons may be disabled */ /* diffexp-related buttons may be disabled */
const { differential, diffexpMayBeSlow, diffexpCellcountMax } = this.props; const { differential, diffexpMayBeSlow, diffexpCellcountMax } = this.props;
const { userDismissedPopover } = this.state;
const haveBothCellSets = const haveBothCellSets =
!!differential.celllist1 && !!differential.celllist2; !!differential.celllist1 && !!differential.celllist2;
@@ -86,7 +65,7 @@ class DiffexpButtons extends React.Component {
diffexpCellcountMax; diffexpCellcountMax;
return ( return (
<ButtonGroup className={styles.menubarButton} > <ButtonGroup className={styles.menubarButton}>
<CellSetButton <CellSetButton
{...this.props} // eslint-disable-line react/jsx-props-no-spreading {...this.props} // eslint-disable-line react/jsx-props-no-spreading
eitherCellSetOneOrTwo={1} eitherCellSetOneOrTwo={1}
+1 -3
View File
@@ -69,9 +69,7 @@ class Embedding extends React.PureComponent {
const { layoutChoice } = this.props; const { layoutChoice } = this.props;
return ( return (
<ButtonGroup <ButtonGroup className={styles.menubarButton}>
className={styles.menubarButton}
>
<Popover <Popover
target={ target={
<Tooltip <Tooltip
+79 -76
View File
@@ -1,7 +1,7 @@
// 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, ButtonGroup, AnchorButton, Tooltip } from "@blueprintjs/core"; import { ButtonGroup, AnchorButton, Tooltip } from "@blueprintjs/core";
import * as globals from "../../globals"; import * as globals from "../../globals";
import styles from "./menubar.css"; import styles from "./menubar.css";
@@ -28,7 +28,7 @@ import DiffexpButtons from "./diffexpButtons";
scatterplotYYaccessor: state.controls.scatterplotYYaccessor, scatterplotYYaccessor: state.controls.scatterplotYYaccessor,
celllist1: state.differential.celllist1, celllist1: state.differential.celllist1,
celllist2: state.differential.celllist2, celllist2: state.differential.celllist2,
libraryVersions: state.config?.library_versions, // eslint-disable-line camelcase libraryVersions: state.config?.["library_versions"],
undoDisabled: state["@@undoable/past"].length === 0, undoDisabled: state["@@undoable/past"].length === 0,
redoDisabled: state["@@undoable/future"].length === 0, redoDisabled: state["@@undoable/future"].length === 0,
aboutLink: state.config?.links?.["about-dataset"], aboutLink: state.config?.links?.["about-dataset"],
@@ -207,9 +207,10 @@ class MenuBar extends React.Component {
const { pendingClipPercentiles } = this.state; const { pendingClipPercentiles } = this.state;
// constants used to create selection tool button // constants used to create selection tool button
const [selectionTooltip, selectionButtonIcon] = selectionTool === "brush" const [selectionTooltip, selectionButtonIcon] =
? ["Brush selection", "Lasso selection"] selectionTool === "brush"
: ["select", "polygon-filter"]; ? ["Brush selection", "Lasso selection"]
: ["select", "polygon-filter"];
return ( return (
<div <div
@@ -245,80 +246,82 @@ class MenuBar extends React.Component {
handleClipCommit={this.handleClipCommit} handleClipCommit={this.handleClipCommit}
isClipDisabled={this.isClipDisabled} isClipDisabled={this.isClipDisabled}
handleClipOnKeyPress={this.handleClipOnKeyPress} handleClipOnKeyPress={this.handleClipOnKeyPress}
handleClipPercentileMaxValueChange={this.handleClipPercentileMaxValueChange} handleClipPercentileMaxValueChange={
handleClipPercentileMinValueChange={this.handleClipPercentileMinValueChange} this.handleClipPercentileMaxValueChange
}
handleClipPercentileMinValueChange={
this.handleClipPercentileMinValueChange
}
/> />
<Embedding /> <Embedding />
<Tooltip <Tooltip
content="When a category is colored by, show labels on the graph" content="When a category is colored by, show labels on the graph"
position="bottom" position="bottom"
disabled={graphInteractionMode === "zoom"} disabled={graphInteractionMode === "zoom"}
> >
<AnchorButton <AnchorButton
className={styles.menubarButton} className={styles.menubarButton}
type="button" type="button"
data-testid="centroid-label-toggle" data-testid="centroid-label-toggle"
icon="property" icon="property"
onClick={this.handleCentroidChange} onClick={this.handleCentroidChange}
active={showCentroidLabels} active={showCentroidLabels}
intent={showCentroidLabels ? "primary" : "none"} intent={showCentroidLabels ? "primary" : "none"}
/> />
</Tooltip> </Tooltip>
<ButtonGroup <ButtonGroup className={styles.menubarButton}>
className={styles.menubarButton} <Tooltip
> content={selectionTooltip}
<Tooltip position="bottom"
content={selectionTooltip} hoverOpenDelay={globals.tooltipHoverOpenDelay}
position="bottom" >
hoverOpenDelay={globals.tooltipHoverOpenDelay} <AnchorButton
> type="button"
<AnchorButton data-testid="mode-lasso"
type="button" icon={selectionButtonIcon}
data-testid="mode-lasso" active={graphInteractionMode === "select"}
icon={selectionButtonIcon} onClick={() => {
active={graphInteractionMode === "select"} dispatch({
onClick={() => { type: "change graph interaction mode",
dispatch({ data: "select",
type: "change graph interaction mode", });
data: "select" }}
}); />
</Tooltip>
<Tooltip
content="Drag to pan, scroll to zoom"
position="bottom"
hoverOpenDelay={globals.tooltipHoverOpenDelay}
>
<AnchorButton
type="button"
data-testid="mode-pan-zoom"
icon="zoom-in"
active={graphInteractionMode === "zoom"}
onClick={() => {
dispatch({
type: "change graph interaction mode",
data: "zoom",
});
}}
/>
</Tooltip>
</ButtonGroup>
<Subset
subsetPossible={this.subsetPossible()}
subsetResetPossible={this.subsetResetPossible()}
handleSubset={() => {
dispatch(actions.setWorldToSelection());
dispatch({ type: "increment graph render counter" });
}}
handleSubsetReset={() => {
dispatch(actions.resetWorldToUniverse());
dispatch({ type: "increment graph render counter" });
}} }}
/> />
</Tooltip> {disableDiffexp ? null : <DiffexpButtons />}
<Tooltip </div>
content="Drag to pan, scroll to zoom" );
position="bottom"
hoverOpenDelay={globals.tooltipHoverOpenDelay}
>
<AnchorButton
type="button"
data-testid="mode-pan-zoom"
icon="zoom-in"
active={graphInteractionMode === "zoom"}
onClick={() => {
dispatch({
type: "change graph interaction mode",
data: "zoom"
});
}}
/>
</Tooltip>
</ButtonGroup>
<Subset
subsetPossible={this.subsetPossible()}
subsetResetPossible={this.subsetResetPossible()}
handleSubset={() => {
dispatch(actions.setWorldToSelection());
dispatch({ type: "increment graph render counter" });
}}
handleSubsetReset={() => {
dispatch(actions.resetWorldToUniverse());
dispatch({ type: "increment graph render counter" });
}}
/>
{disableDiffexp ? null : <DiffexpButtons/>}
</div>
);
} }
} }
@@ -141,7 +141,7 @@ class Scatterplot extends React.PureComponent {
viewport: { viewport: {
height: null, height: null,
width: null, width: null,
} },
}; };
} }
@@ -190,7 +190,7 @@ class Scatterplot extends React.PureComponent {
svg, svg,
drawPoints, drawPoints,
projectionTF, projectionTF,
viewport viewport,
}); });
} }
@@ -286,20 +286,11 @@ class Scatterplot extends React.PureComponent {
return { return {
viewport: { viewport: {
height: window.height, height: window.height,
width: window.width width: window.width,
} },
}; };
}; };
handleResize = () => {
const { state } = this.state;
const viewport = this.getViewportDimensions();
this.setState({
...state,
viewport,
});
};
static setupScales(expressionX, expressionY) { static setupScales(expressionX, expressionY) {
const xScale = d3 const xScale = d3
.scaleLinear() .scaleLinear()
@@ -316,6 +307,15 @@ class Scatterplot extends React.PureComponent {
}; };
} }
handleResize = () => {
const { state } = this.state;
const viewport = this.getViewportDimensions();
this.setState({
...state,
viewport,
});
};
updateViewportDimensions = () => { updateViewportDimensions = () => {
this.setState(this.getViewportDimensions()); this.setState(this.getViewportDimensions());
}; };
@@ -390,7 +390,7 @@ class Scatterplot extends React.PureComponent {
minViewportDimension: Math.min( minViewportDimension: Math.min(
viewport.width - globals.leftSidebarWidth || width, viewport.width - globals.leftSidebarWidth || width,
viewport.height || height viewport.height || height
) ),
}); });
regl._gl.flush(); regl._gl.flush();
} }
+14 -15
View File
@@ -8,8 +8,6 @@ import {
Colors, Colors,
Icon, Icon,
} from "@blueprintjs/core"; } from "@blueprintjs/core";
import * as globals from "../../globals";
import { termsOfServiceToast } from "../framework/toasters";
const CookieDecision = "cxg.cookieDecision"; const CookieDecision = "cxg.cookieDecision";
@@ -26,12 +24,14 @@ function storageGet(key, defaultValue = null) {
function storageSet(key, value) { function storageSet(key, value) {
try { try {
window.localStorage.setItem(key, value); window.localStorage.setItem(key, value);
} catch {} } catch {
// continue
}
} }
@connect((state) => ({ @connect((state) => ({
tosURL: state.config?.parameters?.about_legal_tos, tosURL: state.config?.parameters?.["about_legal_tos"],
privacyURL: state.config?.parameters?.about_legal_privacy, privacyURL: state.config?.parameters?.["about_legal_privacy"],
})) }))
class TermsPrompt extends React.PureComponent { class TermsPrompt extends React.PureComponent {
constructor(props) { constructor(props) {
@@ -59,7 +59,9 @@ class TermsPrompt extends React.PureComponent {
if (window.cookieDecisionCallback instanceof Function) { if (window.cookieDecisionCallback instanceof Function) {
try { try {
window.cookieDecisionCallback(); window.cookieDecisionCallback();
} catch (e) {} } catch (e) {
// continue
}
} }
}; };
@@ -82,6 +84,7 @@ class TermsPrompt extends React.PureComponent {
}} }}
href={tosURL} href={tosURL}
target="_blank" target="_blank"
rel="noopener noreferrer"
> >
terms of service terms of service
</a> </a>
@@ -103,6 +106,7 @@ class TermsPrompt extends React.PureComponent {
}} }}
href={privacyURL} href={privacyURL}
target="_blank" target="_blank"
rel="noopener noreferrer"
> >
privacy policy privacy policy
</a> </a>
@@ -118,12 +122,10 @@ class TermsPrompt extends React.PureComponent {
<Drawer <Drawer
onclose={this.drawerClose} onclose={this.drawerClose}
isOpen={isOpen} isOpen={isOpen}
size={"120px"} size="120px"
position={Position.BOTTOM} position={Position.BOTTOM}
canOutsideClickClose={false} canOutsideClickClose={false}
hasBackdrop={ hasBackdrop /* if the user can't use the app or click outside to dismiss, that should be visually represented with a backdrop */
true /* if the user can't use the app or click outside to dismiss, that should be visually represented with a backdrop */
}
enforceFocus={false} enforceFocus={false}
autoFocus={false} autoFocus={false}
portal={false} portal={false}
@@ -149,12 +151,9 @@ class TermsPrompt extends React.PureComponent {
onClick={this.handleOK} onClick={this.handleOK}
data-testid="tos-cookies-accept" data-testid="tos-cookies-accept"
> >
I'm OK with cookies! I&apos;m OK with cookies!
</Button>{" "} </Button>{" "}
<Button <Button onClick={this.handleNo} data-testid="tos-cookies-reject">
onClick={this.handleNo}
data-testid="tos-cookies-reject"
>
No thanks No thanks
</Button> </Button>
</div> </div>
-1
View File
@@ -1,5 +1,4 @@
// jshint esversion: 6 // jshint esversion: 6
/* eslint-disable no-console */
import React from "react"; import React from "react";
import ReactDOM from "react-dom"; import ReactDOM from "react-dom";
import { Provider } from "react-redux"; import { Provider } from "react-redux";
+1 -1
View File
@@ -150,7 +150,7 @@ const ColorsReducer = (
case "annotation: delete label": { case "annotation: delete label": {
const { world } = nextSharedState; const { world } = nextSharedState;
const { colorMode, colorAccessor } = state; const { colorMode, colorAccessor } = state;
const { metadataField, colors } = action; const { metadataField } = action;
if ( if (
colorMode !== "color by categorical metadata" || colorMode !== "color by categorical metadata" ||
colorAccessor !== metadataField colorAccessor !== metadataField
+1 -1
View File
@@ -49,7 +49,7 @@ const LayoutChoice = (
} }
case "reembed: add reembedding": { case "reembed: add reembedding": {
const name = action.schema.name; const { name } = action.schema;
const available = Array.from(new Set(state.available).add(name)); const available = Array.from(new Set(state.available).add(name));
return { return {
...state, ...state,
+5 -4
View File
@@ -9,11 +9,12 @@ const Ontology = (
) => { ) => {
switch (action.type) { switch (action.type) {
case "configuration load complete": { case "configuration load complete": {
/* eslint-disable camelcase */
const enabled = const enabled =
action.config?.parameters?.annotations_cell_ontology_enabled ?? false; action.config?.parameters?.["annotations_cell_ontology_enabled"] ??
const terms = action.config?.parameters?.annotations_cell_ontology_terms; false;
/* eslint-enable camelcase */ const terms =
action.config?.parameters?.["annotations_cell_ontology_terms"];
const termSet = new Set(terms); const termSet = new Set(terms);
return { return {
...state, ...state,
+3 -3
View File
@@ -41,8 +41,8 @@ const WorldReducer = (
const { dim } = action; const { dim } = action;
// we don't clip anything except for varData and obsAnnotations // we don't clip anything except for varData and obsAnnotations
let unclipped = state.unclipped; let { unclipped } = state;
if (dim == "varData" || dim == "obsAnnotations") { if (dim === "varData" || dim === "obsAnnotations") {
unclipped = { unclipped = {
...unclipped, ...unclipped,
[dim]: universe[dim].clone(), [dim]: universe[dim].clone(),
@@ -298,7 +298,7 @@ const WorldReducer = (
const { obsLayout: origObsLayout, schema: origSchema } = state; const { obsLayout: origObsLayout, schema: origSchema } = state;
const { embedding, schema: embeddingSchema } = action; const { embedding, schema: embeddingSchema } = action;
const { dims, name } = embeddingSchema; const { dims } = embeddingSchema;
let obsLayout = origObsLayout; let obsLayout = origObsLayout;
let schema = origSchema; let schema = origSchema;
+4 -6
View File
@@ -1,3 +1,4 @@
/* eslint-disable max-classes-per-file */
/** /**
Label indexing - map a label to & from an integer offset. See Dataframe Label indexing - map a label to & from an integer offset. See Dataframe
for how this is used. for how this is used.
@@ -23,7 +24,6 @@ function extent(tarr) {
return [min, max]; return [min, max];
} }
/* eslint-disable class-methods-use-this */
class IdentityInt32Index { class IdentityInt32Index {
/* /*
identity/noop index, with small assumptions that labels are int32 identity/noop index, with small assumptions that labels are int32
@@ -41,11 +41,13 @@ class IdentityInt32Index {
return k; return k;
} }
// eslint-disable-next-line class-methods-use-this
getOffset(i) { getOffset(i) {
// label to offset // label to offset
return i; return i;
} }
// eslint-disable-next-line class-methods-use-this
getLabel(i) { getLabel(i) {
// offset to label // offset to label
return i; return i;
@@ -93,9 +95,6 @@ class IdentityInt32Index {
return this.__promote(labelArray); return this.__promote(labelArray);
} }
} }
/* eslint-enable class-methods-use-this */
/* eslint-disable class-methods-use-this */
class DenseInt32Index { class DenseInt32Index {
/* /*
DenseInt32Index indexes integer labels, and uses Int32Array typed arrays DenseInt32Index indexes integer labels, and uses Int32Array typed arrays
@@ -177,9 +176,7 @@ class DenseInt32Index {
return this.__promote(labelArray); return this.__promote(labelArray);
} }
} }
/* eslint-enable class-methods-use-this */
/* eslint-disable class-methods-use-this */
class KeyIndex { class KeyIndex {
/* /*
KeyIndex indexes arbitrary JS primitive types, and uses a Map() KeyIndex indexes arbitrary JS primitive types, and uses a Map()
@@ -223,6 +220,7 @@ class KeyIndex {
return this.rindex.length; return this.rindex.length;
} }
// eslint-disable-next-line class-methods-use-this
subsetLabels(labelArray) { subsetLabels(labelArray) {
return new KeyIndex(labelArray); return new KeyIndex(labelArray);
} }
+1 -1
View File
@@ -13,7 +13,7 @@ return Promise.all([
plimit.add(() => fetch('/baz')) plimit.add(() => fetch('/baz'))
]) ])
*/ */
export class PromiseLimit { export default class PromiseLimit {
constructor(maxConcurrency) { constructor(maxConcurrency) {
this.queue = new Set(); this.queue = new Set();
this.maxConcurrency = maxConcurrency; this.maxConcurrency = maxConcurrency;
+1 -1
View File
@@ -5,6 +5,6 @@
export default (n) => { export default (n) => {
return n return n
.toExponential() .toExponential()
.replace(/e[\+\-0-9]*$/, "") .replace(/e[+\-0-9]*$/, "")
.replace(/^0\.?0*|\./, "").length; .replace(/^0\.?0*|\./, "").length;
}; };
@@ -1,3 +1,4 @@
// eslint-disable-next-line max-classes-per-file
import PositiveIntervals from "./positiveIntervals"; import PositiveIntervals from "./positiveIntervals";
import BitArray from "./bitArray"; import BitArray from "./bitArray";
import { import {
@@ -409,7 +410,7 @@ class ImmutableScalarDimension extends _ImmutableBaseDimension {
this.index = makeSortIndex(array); this.index = makeSortIndex(array);
} }
/* eslint-disable class-methods-use-this */ // eslint-disable-next-line class-methods-use-this
_createValueArray(data, mapf, array) { _createValueArray(data, mapf, array) {
// create dimension value array // create dimension value array
const len = data.length; const len = data.length;
@@ -419,7 +420,6 @@ class ImmutableScalarDimension extends _ImmutableBaseDimension {
} }
return larray; return larray;
} }
/* eslint-enable class-methods-use-this */
select(spec) { select(spec) {
const { mode } = spec; const { mode } = spec;
@@ -466,7 +466,7 @@ class ImmutableScalarDimension extends _ImmutableBaseDimension {
const ranges = []; const ranges = [];
const r = [ const r = [
lowerBoundIndirect(value, index, lo, 0, value.length), lowerBoundIndirect(value, index, lo, 0, value.length),
!!inclusive inclusive
? upperBoundIndirect(value, index, hi, 0, value.length) ? upperBoundIndirect(value, index, hi, 0, value.length)
: lowerBoundIndirect(value, index, hi, 0, value.length), : lowerBoundIndirect(value, index, hi, 0, value.length),
]; ];
@@ -514,11 +514,10 @@ class ImmutableEnumDimension extends ImmutableScalarDimension {
}); });
} }
/* eslint-disable class-methods-use-this */ // eslint-disable-next-line class-methods-use-this
selectRange() { selectRange() {
throw new Error("range selection unsupported on Enumerated dimension"); throw new Error("range selection unsupported on Enumerated dimension");
} }
/* eslint-enable class-methods-use-this */
} }
class ImmutableSpatialDimension extends _ImmutableBaseDimension { class ImmutableSpatialDimension extends _ImmutableBaseDimension {
+1 -1
View File
@@ -105,7 +105,7 @@ def get_data_adaptor(dataset=None):
raise DatasetAccessError("Invalid dataset {dataset}") raise DatasetAccessError("Invalid dataset {dataset}")
if datapath is None: if datapath is None:
return common_rest.abort_and_log(HTTPStatus.BAD_REQUEST, f"Invalid dataset NONE", loglevel=logging.INFO) return common_rest.abort_and_log(HTTPStatus.BAD_REQUEST, "Invalid dataset NONE", loglevel=logging.INFO)
cache_manager = current_app.matrix_data_cache_manager cache_manager = current_app.matrix_data_cache_manager
return cache_manager.data_adaptor(datapath, config) return cache_manager.data_adaptor(datapath, config)
+5 -5
View File
@@ -279,13 +279,13 @@ class AppConfig(object):
if self.server__csp_directives is not None: if self.server__csp_directives is not None:
for k, v in self.server__csp_directives.items(): for k, v in self.server__csp_directives.items():
if not isinstance(k, str): if not isinstance(k, str):
raise ConfigurationError(f"CSP directive names must be a string.") raise ConfigurationError("CSP directive names must be a string.")
if isinstance(v, list): if isinstance(v, list):
for policy in v: for policy in v:
if not isinstance(policy, str): if not isinstance(policy, str):
raise ConfigurationError(f"CSP directive value must be a string or list of strings.") raise ConfigurationError("CSP directive value must be a string or list of strings.")
elif not isinstance(v, str): elif not isinstance(v, str):
raise ConfigurationError(f"CSP directive value must be a string or list of strings.") raise ConfigurationError("CSP directive value must be a string or list of strings.")
# scripts can be string (filename) or dict (attributes). Convert string to dict. # scripts can be string (filename) or dict (attributes). Convert string to dict.
scripts = [] scripts = []
@@ -476,8 +476,8 @@ class AppConfig(object):
with self.matrix_data_cache_manager.data_adaptor(self.single_dataset__datapath, self) as data_adaptor: with self.matrix_data_cache_manager.data_adaptor(self.single_dataset__datapath, self) as data_adaptor:
if self.diffexp__enable and data_adaptor.parameters.get("diffexp_may_be_slow", False): if self.diffexp__enable and data_adaptor.parameters.get("diffexp_may_be_slow", False):
context["messagefn"]( context["messagefn"](
f"CAUTION: due to the size of your dataset, " "CAUTION: due to the size of your dataset, "
f"running differential expression may take longer or fail." "running differential expression may take longer or fail."
) )
max_workers = self.diffexp__alg_cxg__max_workers max_workers = self.diffexp__alg_cxg__max_workers
+5 -5
View File
@@ -183,13 +183,13 @@ class AnndataAdaptor(DataAdaptor):
def _validate_and_initialize(self): def _validate_and_initialize(self):
if anndata_version_is_pre_070() and self.config.adaptor__anndata_adaptor__backed: if anndata_version_is_pre_070() and self.config.adaptor__anndata_adaptor__backed:
warnings.warn( warnings.warn(
f"Use of --backed mode with anndata versions older than 0.7 will have serious " "Use of --backed mode with anndata versions older than 0.7 will have serious "
"performance issues. Please update to at least anndata 0.7 or later." "performance issues. Please update to at least anndata 0.7 or later."
) )
# var and obs column names must be unique # var and obs column names must be unique
if not self.data.obs.columns.is_unique or not self.data.var.columns.is_unique: if not self.data.obs.columns.is_unique or not self.data.var.columns.is_unique:
raise KeyError(f"All annotation column names must be unique.") raise KeyError("All annotation column names must be unique.")
self._alias_annotation_names() self._alias_annotation_names()
self._validate_data_types() self._validate_data_types()
@@ -222,8 +222,8 @@ class AnndataAdaptor(DataAdaptor):
X0 = self.data.X[0, 0:1] X0 = self.data.X[0, 0:1]
if sparse.isspmatrix(X0) and not sparse.isspmatrix_csc(X0): if sparse.isspmatrix(X0) and not sparse.isspmatrix_csc(X0):
warnings.warn( warnings.warn(
f"Anndata data matrix is sparse, but not a CSC (columnar) matrix. " "Anndata data matrix is sparse, but not a CSC (columnar) matrix. "
f"Performance may be improved by using CSC." "Performance may be improved by using CSC."
) )
if self.data.X.dtype != "float32": if self.data.X.dtype != "float32":
warnings.warn( warnings.warn(
@@ -295,7 +295,7 @@ class AnndataAdaptor(DataAdaptor):
valid_layouts.append(layout) valid_layouts.append(layout)
if len(valid_layouts) == 0: if len(valid_layouts) == 0:
raise PrepareError(f"No valid layout data.") raise PrepareError("No valid layout data.")
# cap layouts to MAX_LAYOUTS # cap layouts to MAX_LAYOUTS
return layouts[0:MAX_LAYOUTS] return layouts[0:MAX_LAYOUTS]
+6 -5
View File
@@ -230,18 +230,19 @@ class DataAdaptor(metaclass=ABCMeta):
# all labels must have a name, which must be unique and not used in obs column names # all labels must have a name, which must be unique and not used in obs column names
if not labels_df.columns.is_unique: if not labels_df.columns.is_unique:
raise KeyError(f"All column names specified in user annotations must be unique.") raise KeyError("All column names specified in user annotations must be unique.")
# the label index must be unique, and must have same values the anndata obs index # the label index must be unique, and must have same values the anndata obs index
if not labels_df.index.is_unique: if not labels_df.index.is_unique:
raise KeyError(f"All row index values specified in user annotations must be unique.") raise KeyError("All row index values specified in user annotations must be unique.")
obs_columns = self.get_obs_columns() obs_columns = self.get_obs_columns()
duplicate_columns = list(set(labels_df.columns) & set(obs_columns)) duplicate_columns = list(set(labels_df.columns) & set(obs_columns))
if len(duplicate_columns) > 0: if len(duplicate_columns) > 0:
raise KeyError( raise KeyError(
f"Labels file may not contain column names which overlap " f"with h5ad obs columns {duplicate_columns}" "Labels file may not contain column names which overlap "
f"with h5ad obs columns {duplicate_columns}"
) )
# labels must have same count as obs annotations # labels must have same count as obs annotations
@@ -351,13 +352,13 @@ class DataAdaptor(metaclass=ABCMeta):
""" """
embeddings = self.get_embedding_names() if fields is None or len(fields) == 0 else fields embeddings = self.get_embedding_names() if fields is None or len(fields) == 0 else fields
layout_data = [] layout_data = []
with ServerTiming.time(f"layout.query"): with ServerTiming.time("layout.query"):
for ename in embeddings: for ename in embeddings:
embedding = self.get_embedding_array(ename, 2) embedding = self.get_embedding_array(ename, 2)
normalized_layout = DataAdaptor.normalize_embedding(embedding) normalized_layout = DataAdaptor.normalize_embedding(embedding)
layout_data.append(pd.DataFrame(normalized_layout, columns=[f"{ename}_0", f"{ename}_1"])) layout_data.append(pd.DataFrame(normalized_layout, columns=[f"{ename}_0", f"{ename}_1"]))
with ServerTiming.time(f"layout.encode"): with ServerTiming.time("layout.encode"):
if layout_data: if layout_data:
df = pd.concat(layout_data, axis=1, copy=False) df = pd.concat(layout_data, axis=1, copy=False)
else: else:
+2 -2
View File
@@ -228,7 +228,7 @@ class MatrixDataLoader(object):
self.matrix_data_type = self.__matrix_data_type() self.matrix_data_type = self.__matrix_data_type()
if not self.__matrix_data_type_allowed(app_config): if not self.__matrix_data_type_allowed(app_config):
raise DatasetAccessError(f"Dataset does not have an allowed type.") raise DatasetAccessError("Dataset does not have an allowed type.")
if self.matrix_data_type == MatrixDataType.H5AD: if self.matrix_data_type == MatrixDataType.H5AD:
from server.data_anndata.anndata_adaptor import AnndataAdaptor from server.data_anndata.anndata_adaptor import AnndataAdaptor
@@ -272,7 +272,7 @@ class MatrixDataLoader(object):
def pre_load_validation(self): def pre_load_validation(self):
if self.matrix_data_type == MatrixDataType.UNKNOWN: if self.matrix_data_type == MatrixDataType.UNKNOWN:
raise DatasetAccessError(f"Dataset does not have a recognized type: .h5ad or .cxg") raise DatasetAccessError("Dataset does not have a recognized type: .h5ad or .cxg")
self.matrix_type.pre_load_validation(self.location) self.matrix_type.pre_load_validation(self.location)
def file_size(self): def file_size(self):
+2 -2
View File
@@ -264,7 +264,7 @@ class CxgAdaptor(DataAdaptor):
# function to get the embedding # function to get the embedding
# this function to iterate through embeddings. # this function to iterate through embeddings.
def get_embedding_names(self): def get_embedding_names(self):
with ServerTiming.time(f"layout.lsuri"): with ServerTiming.time("layout.lsuri"):
pemb = self.get_path("emb") pemb = self.get_path("emb")
embeddings = [os.path.basename(p) for (p, t) in self.lsuri(pemb) if t == "array"] embeddings = [os.path.basename(p) for (p, t) in self.lsuri(pemb) if t == "array"]
if len(embeddings) == 0: if len(embeddings) == 0:
@@ -311,7 +311,7 @@ class CxgAdaptor(DataAdaptor):
A = self.open_array(ax) A = self.open_array(ax)
schema_hints = json.loads(A.meta["cxg_schema"]) if "cxg_schema" in A.meta else {} schema_hints = json.loads(A.meta["cxg_schema"]) if "cxg_schema" in A.meta else {}
if type(schema_hints) is not dict: if type(schema_hints) is not dict:
raise TypeError(f"Array schema was malformed.") raise TypeError("Array schema was malformed.")
cols = [] cols = []
for attr in A.schema: for attr in A.schema:
+3 -3
View File
@@ -156,7 +156,7 @@ try:
dataroot = os.getenv("CXG_DATAROOT") dataroot = os.getenv("CXG_DATAROOT")
if dataroot: if dataroot:
logging.info(f"Configuration from CXG_DATAROOT") logging.info("Configuration from CXG_DATAROOT")
app_config.update(multi_dataset__dataroot=dataroot) app_config.update(multi_dataset__dataroot=dataroot)
secret_name = os.getenv("CXG_AWS_SECRET_NAME") secret_name = os.getenv("CXG_AWS_SECRET_NAME")
@@ -171,7 +171,7 @@ try:
if not secret_region_name: if not secret_region_name:
secret_region_name = discover_s3_region_name(config_file) secret_region_name = discover_s3_region_name(config_file)
if not secret_region_name: if not secret_region_name:
logging.error(f"Could not determine the AWS Secret Manager region") logging.error("Could not determine the AWS Secret Manager region")
sys.exit(1) sys.exit(1)
flask_secret_key = get_flask_secret_key(secret_region_name, secret_name) flask_secret_key = get_flask_secret_key(secret_region_name, secret_name)
@@ -188,7 +188,7 @@ try:
if not app_config.server__flask_secret_key: if not app_config.server__flask_secret_key:
logging.critical( logging.critical(
f"flask_secret_key is not provided. Either set in config file, CXG_SECRET_KEY environment variable, " "flask_secret_key is not provided. Either set in config file, CXG_SECRET_KEY environment variable, "
"or in AWS Secret Manager" "or in AWS Secret Manager"
) )
sys.exit(1) sys.exit(1)
+3 -9
View File
@@ -29,16 +29,12 @@ def check(expected, custom):
# cdict must only have exact requirements (==) # cdict must only have exact requirements (==)
for cname, cspecs in cdict.items(): for cname, cspecs in cdict.items():
if len(cspecs) != 1 or cspecs[0][0] != "==": if len(cspecs) != 1 or cspecs[0][0] != "==":
print( print(f"Error, spec must be an exact requirement {custom}: {cname} {str(cspecs)}")
f"Error, spec must be an exact requirement {custom}: {cname} {str(cspecs)}"
)
okay = False okay = False
for ename, especs in edict.items(): for ename, especs in edict.items():
if ename not in cdict: if ename not in cdict:
print( print(f"Error, missing requirement from {custom}: {ename} {str(especs)}")
f"Error, missing requirement from {custom}: {ename} {str(especs)}"
)
okay = False okay = False
continue continue
@@ -46,9 +42,7 @@ def check(expected, custom):
for espec in especs: for espec in especs:
rokay = check_version(cver, espec[0], Version(espec[1])) rokay = check_version(cver, espec[0], Version(espec[1]))
if not rokay: if not rokay:
print( print(f"Error, failed requirement from {custom}: {ename} {espec}, {cver}")
f"Error, failed requirement from {custom}: {ename} {espec}, {cver}"
)
okay = False okay = False
if okay: if okay:
+1 -1
View File
@@ -1,6 +1,6 @@
anndata>=0.6.20 anndata>=0.6.20
boto3>=1.12.18 boto3>=1.12.18
click>=6.7 click>=7.1.2
fastobo>=0.6.1 fastobo>=0.6.1
Flask>=1.0.2 Flask>=1.0.2
Flask-Compress>=1.4.0 Flask-Compress>=1.4.0
+8 -8
View File
@@ -185,14 +185,14 @@ class EndPoints(object):
self.assertEqual(result.status_code, HTTPStatus.BAD_REQUEST) self.assertEqual(result.status_code, HTTPStatus.BAD_REQUEST)
def test_data_mimetype_error(self): def test_data_mimetype_error(self):
endpoint = f"data/var" endpoint = "data/var"
header = {"Accept": "xxx"} header = {"Accept": "xxx"}
url = f"{self.URL_BASE}{endpoint}" url = f"{self.URL_BASE}{endpoint}"
result = self.session.put(url, headers=header) result = self.session.put(url, headers=header)
self.assertEqual(result.status_code, HTTPStatus.NOT_ACCEPTABLE) self.assertEqual(result.status_code, HTTPStatus.NOT_ACCEPTABLE)
def test_fbs_default(self): def test_fbs_default(self):
endpoint = f"data/var" endpoint = "data/var"
url = f"{self.URL_BASE}{endpoint}" url = f"{self.URL_BASE}{endpoint}"
result = self.session.put(url) result = self.session.put(url)
self.assertEqual(result.status_code, HTTPStatus.BAD_REQUEST) self.assertEqual(result.status_code, HTTPStatus.BAD_REQUEST)
@@ -202,21 +202,21 @@ class EndPoints(object):
self.assertEqual(result.headers["Content-Type"], "application/octet-stream") self.assertEqual(result.headers["Content-Type"], "application/octet-stream")
def test_data_put_fbs(self): def test_data_put_fbs(self):
endpoint = f"data/var" endpoint = "data/var"
url = f"{self.URL_BASE}{endpoint}" url = f"{self.URL_BASE}{endpoint}"
header = {"Accept": "application/octet-stream"} header = {"Accept": "application/octet-stream"}
result = self.session.put(url, headers=header) result = self.session.put(url, headers=header)
self.assertEqual(result.status_code, HTTPStatus.BAD_REQUEST) self.assertEqual(result.status_code, HTTPStatus.BAD_REQUEST)
def test_data_get_fbs(self): def test_data_get_fbs(self):
endpoint = f"data/var" endpoint = "data/var"
url = f"{self.URL_BASE}{endpoint}" url = f"{self.URL_BASE}{endpoint}"
header = {"Accept": "application/octet-stream"} header = {"Accept": "application/octet-stream"}
result = self.session.get(url, headers=header) result = self.session.get(url, headers=header)
self.assertEqual(result.status_code, HTTPStatus.BAD_REQUEST) self.assertEqual(result.status_code, HTTPStatus.BAD_REQUEST)
def test_data_put_filter_fbs(self): def test_data_put_filter_fbs(self):
endpoint = f"data/var" endpoint = "data/var"
url = f"{self.URL_BASE}{endpoint}" url = f"{self.URL_BASE}{endpoint}"
header = {"Accept": "application/octet-stream"} header = {"Accept": "application/octet-stream"}
filter = {"filter": {"var": {"index": [0, 1, 4]}}} filter = {"filter": {"var": {"index": [0, 1, 4]}}}
@@ -233,8 +233,8 @@ class EndPoints(object):
def test_data_get_filter_fbs(self): def test_data_get_filter_fbs(self):
index_col_name = self.schema["schema"]["annotations"]["var"]["index"] index_col_name = self.schema["schema"]["annotations"]["var"]["index"]
endpoint = "data/var"
query = f"var:{index_col_name}=SIK1" query = f"var:{index_col_name}=SIK1"
endpoint = f"data/var"
url = f"{self.URL_BASE}{endpoint}?{query}" url = f"{self.URL_BASE}{endpoint}?{query}"
header = {"Accept": "application/octet-stream"} header = {"Accept": "application/octet-stream"}
result = self.session.get(url, headers=header) result = self.session.get(url, headers=header)
@@ -245,7 +245,7 @@ class EndPoints(object):
self.assertEqual(df["n_cols"], 1) self.assertEqual(df["n_cols"], 1)
def test_data_put_single_var(self): def test_data_put_single_var(self):
endpoint = f"data/var" endpoint = "data/var"
url = f"{self.URL_BASE}{endpoint}" url = f"{self.URL_BASE}{endpoint}"
header = {"Accept": "application/octet-stream"} header = {"Accept": "application/octet-stream"}
index_col_name = self.schema["schema"]["annotations"]["var"]["index"] index_col_name = self.schema["schema"]["annotations"]["var"]["index"]
@@ -306,7 +306,7 @@ class EndPointsAnnotations(EndPoints):
query = "annotation-collection-name=test_annotations" query = "annotation-collection-name=test_annotations"
url = f"{self.URL_BASE}{endpoint}?{query}" url = f"{self.URL_BASE}{endpoint}?{query}"
n_rows = self.data.get_shape()[0] n_rows = self.data.get_shape()[0]
fbs = make_fbs({"cat_A": pd.Series(["label_A" for l in range(0, n_rows)], dtype="category")}) fbs = make_fbs({"cat_A": pd.Series(["label_A"] * n_rows, dtype="category")})
result = self.session.put(url, data=fbs) result = self.session.put(url, data=fbs)
self.assertEqual(result.status_code, HTTPStatus.OK) self.assertEqual(result.status_code, HTTPStatus.OK)
self.assertEqual(result.headers["Content-Type"], "application/json") self.assertEqual(result.headers["Content-Type"], "application/json")
+15 -15
View File
@@ -27,7 +27,7 @@ class WritableAnnotationTest(unittest.TestCase):
def test_error_checks(self): def test_error_checks(self):
# verify that the expected errors are generated # verify that the expected errors are generated
n_rows = self.data.get_shape()[0] n_rows = self.data.get_shape()[0]
fbs_bad = make_fbs({"louvain": pd.Series(["undefined" for l in range(0, n_rows)], dtype="category")}) fbs_bad = make_fbs({"louvain": pd.Series(["undefined"] * n_rows, dtype="category")})
# ensure we catch attempt to overwrite non-writable data # ensure we catch attempt to overwrite non-writable data
with self.assertRaises(KeyError): with self.assertRaises(KeyError):
@@ -38,8 +38,8 @@ class WritableAnnotationTest(unittest.TestCase):
n_rows = self.data.get_shape()[0] n_rows = self.data.get_shape()[0]
fbs = make_fbs( fbs = make_fbs(
{ {
"cat_A": pd.Series(["label_A" for l in range(0, n_rows)], dtype="category"), "cat_A": pd.Series(["label_A"] * n_rows, dtype="category"),
"cat_B": pd.Series(["label_B" for l in range(0, n_rows)], dtype="category"), "cat_B": pd.Series(["label_B"] * n_rows, dtype="category"),
} }
) )
res = self.annotation_put_fbs(fbs) res = self.annotation_put_fbs(fbs)
@@ -49,14 +49,14 @@ class WritableAnnotationTest(unittest.TestCase):
self.assertEqual(df.shape, (n_rows, 2)) self.assertEqual(df.shape, (n_rows, 2))
self.assertEqual(set(df.columns), {"cat_A", "cat_B"}) self.assertEqual(set(df.columns), {"cat_A", "cat_B"})
self.assertTrue(self.data.original_obs_index.equals(df.index)) self.assertTrue(self.data.original_obs_index.equals(df.index))
self.assertTrue(np.all(df["cat_A"] == ["label_A" for l in range(0, n_rows)])) self.assertTrue(np.all(df["cat_A"] == ["label_A"] * n_rows))
self.assertTrue(np.all(df["cat_B"] == ["label_B" for l in range(0, n_rows)])) self.assertTrue(np.all(df["cat_B"] == ["label_B"] * n_rows))
# verify complete overwrite on second attempt, AND rotation occurs # verify complete overwrite on second attempt, AND rotation occurs
fbs = make_fbs( fbs = make_fbs(
{ {
"cat_A": pd.Series(["label_A1" for l in range(0, n_rows)], dtype="category"), "cat_A": pd.Series(["label_A1"] * n_rows, dtype="category"),
"cat_C": pd.Series(["label_C" for l in range(0, n_rows)], dtype="category"), "cat_C": pd.Series(["label_C"] * n_rows, dtype="category"),
} }
) )
res = self.annotation_put_fbs(fbs) res = self.annotation_put_fbs(fbs)
@@ -64,8 +64,8 @@ class WritableAnnotationTest(unittest.TestCase):
self.assertTrue(path.exists(self.annotations.output_file)) self.assertTrue(path.exists(self.annotations.output_file))
df = pd.read_csv(self.annotations.output_file, index_col=0, header=0, comment="#") df = pd.read_csv(self.annotations.output_file, index_col=0, header=0, comment="#")
self.assertEqual(set(df.columns), {"cat_A", "cat_C"}) self.assertEqual(set(df.columns), {"cat_A", "cat_C"})
self.assertTrue(np.all(df["cat_A"] == ["label_A1" for l in range(0, n_rows)])) self.assertTrue(np.all(df["cat_A"] == ["label_A1"] * n_rows))
self.assertTrue(np.all(df["cat_C"] == ["label_C" for l in range(0, n_rows)])) self.assertTrue(np.all(df["cat_C"] == ["label_C"] * n_rows))
# rotation # rotation
name, ext = path.splitext(self.annotations.output_file) name, ext = path.splitext(self.annotations.output_file)
@@ -79,8 +79,8 @@ class WritableAnnotationTest(unittest.TestCase):
n_rows = self.data.get_shape()[0] n_rows = self.data.get_shape()[0]
fbs = make_fbs( fbs = make_fbs(
{ {
"cat_A": pd.Series(["label_A" for l in range(0, n_rows)], dtype="category"), "cat_A": pd.Series(["label_A"] * n_rows, dtype="category"),
"cat_B": pd.Series(["label_B" for l in range(0, n_rows)], dtype="category"), "cat_B": pd.Series(["label_B"] * n_rows, dtype="category"),
} }
) )
for i in range(0, 11): for i in range(0, 11):
@@ -100,8 +100,8 @@ class WritableAnnotationTest(unittest.TestCase):
n_rows = self.data.get_shape()[0] n_rows = self.data.get_shape()[0]
fbs = make_fbs( fbs = make_fbs(
{ {
"cat_A": pd.Series(["label_A" for l in range(0, n_rows)], dtype="category"), "cat_A": pd.Series(["label_A"] * n_rows, dtype="category"),
"cat_B": pd.Series(["label_B" for l in range(0, n_rows)], dtype="category"), "cat_B": pd.Series(["label_B"] * n_rows, dtype="category"),
} }
) )
@@ -123,8 +123,8 @@ class WritableAnnotationTest(unittest.TestCase):
[obs_index_col_name, "n_genes", "percent_mito", "n_counts", "louvain", "cat_A", "cat_B"], [obs_index_col_name, "n_genes", "percent_mito", "n_counts", "louvain", "cat_A", "cat_B"],
) )
col_idx = annotations["col_idx"] col_idx = annotations["col_idx"]
self.assertEqual(annotations["columns"][col_idx.index("cat_A")], ["label_A" for l in range(0, n_rows)]) self.assertEqual(annotations["columns"][col_idx.index("cat_A")], ["label_A"] * n_rows)
self.assertEqual(annotations["columns"][col_idx.index("cat_B")], ["label_B" for l in range(0, n_rows)]) self.assertEqual(annotations["columns"][col_idx.index("cat_B")], ["label_B"] * n_rows)
# verify the schema was updated # verify the schema was updated
all_col_schema = {c["name"]: c for c in schema["annotations"]["obs"]["columns"]} all_col_schema = {c["name"]: c for c in schema["annotations"]["obs"]["columns"]}