Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/translate query to python snippet #98

Merged
merged 8 commits into from
May 22, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,39 @@
.code {
cursor: text;
user-select: text;
}
}

.button-menu, .button-menu button {
background-color: var(--secondary-background-color);
color: var(--secondary-text-color);
}

.button-menu i, .button-menu li > div {
color: var(--secondary-text-color);
}

.button-menu :is(a, button):hover, .button-menu button:hover i {
background-color: var(--highlight-background-color);
color: var(--highlight-text-color) !important;
}

.code-actions {
max-width: 100%;
padding: 0 var(--padding) 0 var(--padding);

/* flex parent */
display: flex;
}

.action-button {
background-color: var(--primary-background-color);
border: none;
border-radius: 0;
color: var(--primary-text-color);
/* height: 30px;*/
width: 150px;
}

.action-button i, .action-button:hover i {
color: var(--primary-text-color) !important;
}
30 changes: 29 additions & 1 deletion packages/core/components/Modal/CodeSnippet/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { IconButton, TooltipHost } from "@fluentui/react";
import { ActionButton, IContextualMenuItem, IconButton, TooltipHost } from "@fluentui/react";
import classNames from "classnames";
import * as React from "react";
import { useSelector } from "react-redux";
import SyntaxHighlighter from "react-syntax-highlighter";
Expand All @@ -18,9 +19,19 @@ export default function CodeSnippet({ onDismiss }: ModalProps) {
const pythonSnippet = useSelector(interaction.selectors.getPythonSnippet);
const code = pythonSnippet?.code;
const setup = pythonSnippet?.setup;
const languageOptions: IContextualMenuItem[] = [
{
key: "python",
text: "Python (pandas)",
onClick() {
setLanguage("Python (pandas)");
},
},
];

const [isSetupCopied, setSetupCopied] = React.useState(false);
const [isCodeCopied, setCodeCopied] = React.useState(false);
const [language, setLanguage] = React.useState(languageOptions[0].text);

const onCopySetup = () => {
setup && navigator.clipboard.writeText(setup);
Expand All @@ -44,6 +55,19 @@ export default function CodeSnippet({ onDismiss }: ModalProps) {

const body = (
<>
<div className={styles.header}>
<h4>Language</h4>
<div className={styles.codeActions}>
<ActionButton
className={classNames(styles.actionButton, styles.copyButton)}
menuProps={{
className: styles.buttonMenu,
items: languageOptions,
}}
text={language}
/>
</div>
</div>
<div className={styles.header}>
<h4>Setup</h4>
<TooltipHost content={isSetupCopied ? "Copied to clipboard!" : undefined}>
Expand Down Expand Up @@ -75,6 +99,10 @@ export default function CodeSnippet({ onDismiss }: ModalProps) {
</div>
<SyntaxHighlighter
className={styles.code}
lineProps={{ style: { wordBreak: "break-all", whiteSpace: "pre-wrap" } }}
wrapLines
showLineNumbers={false}
showInlineLineNumbers={false}
language="python"
onMouseDown={stopPropagationHandler}
onMouseMove={stopPropagationHandler}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ describe("<CodeSnippet />", () => {
it("displays snippet when present in state", async () => {
// Arrange
const setup = "pip install pandas";
const code = "TODO";
const code = "#No options selected";
const { store } = configureMockStore({ state: visibleDialogState });
const { findByText } = render(
<Provider store={store}>
Expand Down
75 changes: 75 additions & 0 deletions packages/core/entity/FileExplorerURL/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,4 +157,79 @@ export default class FileExplorerURL {
sortColumn,
};
}

public static convertToPython(urlComponents: Partial<FileExplorerURLComponents>) {
const collectionString = this.convertCollectionToPython(urlComponents?.collection);

const groupByQueryString =
urlComponents.hierarchy
?.map((annotation) => this.convertGroupByToPython(annotation))
.join("") || "";

// Group filters by name and use OR to concatenate same filter values
const filterGroups = new Map();
urlComponents.filters?.forEach((filter) => {
const pythonQueryString = filterGroups.get(filter.name);
if (!pythonQueryString) {
filterGroups.set(filter.name, this.convertFilterToPython(filter));
} else {
filterGroups.set(
filter.name,
pythonQueryString.concat(` | ${this.convertFilterToPython(filter)}`)
);
}
});

// Chain the filters together
let filterQueryString = "";
filterGroups.forEach((value) => {
filterQueryString = filterQueryString.concat(`.query('${value}')`);
});

const sortQueryString = urlComponents.sortColumn
? this.convertSortToPython(urlComponents.sortColumn)
: "";
// const fuzzy = [] // TO DO: support fuzzy filtering

const hasQueryElements = groupByQueryString || filterQueryString || sortQueryString;
const imports = "import pandas\n";
aswallace marked this conversation as resolved.
Show resolved Hide resolved
const comment = hasQueryElements ? "#Query on dataframe df" : "#No options selected";
const fullQueryString = `${comment}${
aswallace marked this conversation as resolved.
Show resolved Hide resolved
hasQueryElements && `\ndf${groupByQueryString}${filterQueryString}${sortQueryString}`
}`;
return `${imports}${collectionString}${fullQueryString}`;
}

private static convertSortToPython(sortColumn: FileSort) {
return `.sort_values(by='${sortColumn.annotationName}', ascending=${
sortColumn.order == "ASC" ? "True" : "False"
})`;
}

private static convertGroupByToPython(annotation: string) {
return `.groupby('${annotation}', group_keys=True).apply(lambda x: x)`;
}

private static convertFilterToPython(filter: FileFilter) {
// TO DO: Support querying non-string types
if (filter.value.includes("RANGE")) {
return;
// let begin, end;
// return `\`${filter.name}\`>="${begin}"&\`${filter.name}\`<"${end}"`
}
return `\`${filter.name}\`=="${filter.value}"`;
}

private static convertCollectionToPython(collection: Collection | undefined) {
if (collection?.uri) {
const comment = "#Convert current datasource file to a pandas dataframe";
const extension = collection.uri.substring(collection.uri.lastIndexOf(".") + 1);
// Currently suggest setting all fields to strings; otherwise pandas assumes type conversions
// TO DO: Address different non-string type conversions
const code = `df = pandas.read_${extension}('${collection.uri}').astype('str')`;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per the comment: Even though external data sources are read into FES app as strings, when pandas converts them to a dataframe it tries to guess datatypes unless we strictly enforce .astype

aswallace marked this conversation as resolved.
Show resolved Hide resolved
// This only works if we assume that the file types will only be csv, parquet or json
return `${comment}\n${code}\n\n`;
}
return "";
}
}
121 changes: 121 additions & 0 deletions packages/core/entity/FileExplorerURL/test/fileexplorerurl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ describe("FileExplorerURL", () => {
private: true,
created: new Date(),
createdBy: "test",
uri: "test/file.csv",
};

describe("encode", () => {
Expand Down Expand Up @@ -196,4 +197,124 @@ describe("FileExplorerURL", () => {
expect(() => FileExplorerURL.decode(encodedUrl)).to.throw();
});
});

describe("convert to python pandas string", () => {
it("converts groupings", () => {
// Arrange
const expectedAnnotationNames = ["Cell Line", "Donor Plasmid", "Lifting?"];
const components: Partial<FileExplorerURLComponents> = {
hierarchy: expectedAnnotationNames,
};
const expectedPandasGroups = expectedAnnotationNames.map(
(annotation) => `.groupby('${annotation}', group_keys=True).apply(lambda x: x)`
);
const expectedResult = `df${expectedPandasGroups.join("")}`;

// Act
const result = FileExplorerURL.convertToPython(components);

// Assert
expect(result).to.contain(expectedResult);
});

it("converts filters", () => {
// Arrange
const expectedFilters = [
{ name: "Cas9", value: "spCas9" },
{ name: "Donor Plasmid", value: "ACTB-mEGFP" },
];
const components: Partial<FileExplorerURLComponents> = {
filters: expectedFilters.map(({ name, value }) => new FileFilter(name, value)),
};
const expectedPandasQueries = expectedFilters.map(
(filter) => `\`${filter.name}\`=="${filter.value}"`
);
const expectedResult = `df.query('${expectedPandasQueries[0]}').query('${expectedPandasQueries[1]}')`;

// Act
const result = FileExplorerURL.convertToPython(components);

// Assert
expect(result).to.contain(expectedResult);
});

it("converts same filter with multiple values", () => {
// Arrange
const expectedFilters = [
{ name: "Gene", value: "AAVS1" },
{ name: "Gene", value: "ACTB" },
];
const components: Partial<FileExplorerURLComponents> = {
filters: expectedFilters.map(({ name, value }) => new FileFilter(name, value)),
};
const expectedPandasQueries = expectedFilters.map(
(filter) => `\`${filter.name}\`=="${filter.value}"`
);
const expectedResult = `df.query('${expectedPandasQueries[0]} | ${expectedPandasQueries[1]}')`;

// Act
const result = FileExplorerURL.convertToPython(components);

// Assert
expect(result).to.contain(expectedResult);
});

it("converts sorts", () => {
// Arrange
const components: Partial<FileExplorerURLComponents> = {
sortColumn: new FileSort(AnnotationName.UPLOADED, SortOrder.DESC),
};
const expectedPandasSort = `.sort_values(by='${AnnotationName.UPLOADED}', ascending=False`;
const expectedResult = `df${expectedPandasSort}`;

// Act
const result = FileExplorerURL.convertToPython(components);

// Assert
expect(result).to.contain(expectedResult);
});

it("provides info on converting external data source to pandas dataframe", () => {
// Arrange
const components: Partial<FileExplorerURLComponents> = {
collection: {
name: mockCollection.name,
version: mockCollection.version,
uri: mockCollection.uri,
},
};
const expectedResult = `df = pandas.read_csv('${mockCollection.uri}').astype('str')`;

// Act
const result = FileExplorerURL.convertToPython(components);

// Assert
expect(result).to.contain(expectedResult);
});

it("arranges query elements in correct order", () => {
// Arrange
const expectedAnnotationNames = ["Plate Barcode"];
const expectedFilters = [
{ name: "Cas9", value: "spCas9" },
{ name: "Donor Plasmid", value: "ACTB-mEGFP" },
];
const components: Partial<FileExplorerURLComponents> = {
hierarchy: expectedAnnotationNames,
filters: expectedFilters.map(({ name, value }) => new FileFilter(name, value)),
sortColumn: new FileSort(AnnotationName.UPLOADED, SortOrder.DESC),
collection: {
name: mockCollection.name,
version: mockCollection.version,
},
};
const expectedResult = /df\.groupby\(.*\)\.query\(.*\)\.query\(.*\)\.sort_values\(.*\)/i;

// Act
const result = FileExplorerURL.convertToPython(components);

// Assert
expect(result).to.match(expectedResult);
});
});
});
9 changes: 4 additions & 5 deletions packages/core/state/interaction/selectors.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createSelector } from "reselect";

import { State } from "../";
import { getCollection } from "../selection/selectors";
import { getCollection, getPythonConversion } from "../selection/selectors";
import { AnnotationService, FileService, HttpServiceBase } from "../../services";
import DatasetService, { PythonicDataAccessSnippet } from "../../services/DatasetService";
import DatabaseAnnotationService from "../../services/AnnotationService/DatabaseAnnotationService";
Expand Down Expand Up @@ -32,12 +32,11 @@ export const getUserSelectedApplications = (state: State) =>
export const getVisibleModal = (state: State) => state.interaction.visibleModal;

// COMPOSED SELECTORS
// TODO: Implement PythonicDataAccessSnippet
export const getPythonSnippet = createSelector(
[],
(): PythonicDataAccessSnippet => {
[getPythonConversion],
(pythonQuery): PythonicDataAccessSnippet => {
const setup = "pip install pandas";
const code = "TODO";
const code = `${pythonQuery}`;

return { setup, code };
}
Expand Down
19 changes: 19 additions & 0 deletions packages/core/state/selection/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,25 @@ export const getEncodedFileExplorerUrl = createSelector(
}
);

export const getPythonConversion = createSelector(
[getAnnotationHierarchy, getFileFilters, getOpenFileFolders, getSortColumn, getCollection],
(
hierarchy: string[],
filters: FileFilter[],
openFolders: FileFolder[],
sortColumn?: FileSort,
collection?: Dataset
) => {
return FileExplorerURL.convertToPython({
hierarchy,
filters,
openFolders,
sortColumn,
collection,
});
}
);

export const getGroupedByFilterName = createSelector(
[getFileFilters, getAnnotations],
(globalFilters: FileFilter[], annotations: Annotation[]) => {
Expand Down
Loading