Skip to content

Commit

Permalink
Merge pull request #98 from AllenInstitute/feature/translate-query-to…
Browse files Browse the repository at this point in the history
…-python-snippet

Feature/translate query to python snippet
  • Loading branch information
aswallace authored May 22, 2024
2 parents 5e02088 + afb7f3c commit ab23c6b
Show file tree
Hide file tree
Showing 9 changed files with 402 additions and 20 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,39 @@
.code {
cursor: text;
user-select: text !important;
}
min-width: 300px;
}

.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);
width: 200px;
}

.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 3.8+ (pandas)",
onClick() {
setLanguage("Python 3.8+ (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
@@ -1,5 +1,5 @@
import { configureMockStore, mergeState } from "@aics/redux-utils";
import { render } from "@testing-library/react";
import { render, screen } from "@testing-library/react";
import { expect } from "chai";
import * as React from "react";
import { Provider } from "react-redux";
Expand All @@ -12,6 +12,11 @@ describe("<CodeSnippet />", () => {
interaction: {
visibleModal: ModalType.CodeSnippet,
},
selection: {
dataSource: {
uri: "fake-uri.test",
},
},
});

it("is visible when should not be hidden", () => {
Expand All @@ -30,8 +35,8 @@ describe("<CodeSnippet />", () => {

it("displays snippet when present in state", async () => {
// Arrange
const setup = "pip install pandas";
const code = "TODO";
const setup = /pip install (")?pandas/;
const code = "#No options selected";
const { store } = configureMockStore({ state: visibleDialogState });
const { findByText } = render(
<Provider store={store}>
Expand All @@ -40,7 +45,30 @@ describe("<CodeSnippet />", () => {
);

// Assert
expect(await findByText(setup)).to.exist;
expect(screen.findByText((_, element) => element?.textContent?.match(setup) !== null)).to
.exist;
expect(await findByText(code)).to.exist;
});

it("displays temporary 'coming soon' message for internal data sources", async () => {
// Arrange
const code = "# Coming soon";
const internalDataSourceState = {
...visibleDialogState,
selection: {
dataSource: {
uri: undefined,
},
},
};
const { store } = configureMockStore({ state: internalDataSourceState });
const { findByText } = render(
<Provider store={store}>
<Modal />
</Provider>
);

// Assert
expect(await findByText(code)).to.exist;
});
});
102 changes: 102 additions & 0 deletions packages/core/entity/FileExplorerURL/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { AnnotationName } from "../Annotation";
import FileFilter from "../FileFilter";
import FileFolder from "../FileFolder";
import FileSort, { SortOrder } from "../FileSort";
import { AICS_FMS_DATA_SOURCE_NAME } from "../../constants";

export interface Source {
name: string;
Expand Down Expand Up @@ -118,4 +119,105 @@ export default class FileExplorerURL {
.map((parsedFolder) => new FileFolder(parsedFolder)),
};
}

public static convertToPython(
urlComponents: Partial<FileExplorerURLComponents>,
userOS: string
) {
if (
urlComponents?.source?.name === AICS_FMS_DATA_SOURCE_NAME ||
!urlComponents?.source?.uri
) {
return "# Coming soon";
}
const sourceString = this.convertDataSourceToPython(urlComponents?.source, userOS);
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 as pd\n\n";
const comment = hasQueryElements ? "#Query on dataframe df" : "#No options selected";
const fullQueryString = `${comment}${
hasQueryElements &&
`\ndf_queried = df${groupByQueryString}${filterQueryString}${sortQueryString}`
}`;
return `${imports}${sourceString}${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 convertDataSourceToPython(source: Source | undefined, userOS: string) {
const isUsingWindowsOS = userOS === "Windows_NT" || userOS.includes("Windows NT");
const rawFlagForWindows = isUsingWindowsOS ? "r" : "";

if (typeof source?.uri === "string") {
const comment = "#Convert current datasource file to a pandas dataframe";

// Currently suggest setting all fields to strings; otherwise pandas assumes type conversions
// TO DO: Address different non-string type conversions
const code = `df = pd.read_${source.type}(${rawFlagForWindows}'${source.uri}').astype('str')`;
// This only works if we assume that the file types will only be csv, parquet or json

return `${comment}\n${code}\n\n`;
} else if (source?.uri) {
// Any other type, i.e., File. `instanceof` breaks testing library
// Adding strings to avoid including unwanted white space
const inputFileLineComment =
" # Unable to automatically determine " +
"local file location in the browser. Modify this variable to " +
"represent the full path to your .csv, .json, or .parquet data sources\n";
const inputFileError =
"if not input_file:\n" +
'\traise Exception("Must supply the data source location for the query")\n';
const inputFileCode = 'input_file = ""' + inputFileLineComment + inputFileError;

const conversionCode = `df = pd.read_${source.type}(input_file).astype('str')`;
return `${inputFileCode}\n${conversionCode}\n\n`;
} else return ""; // Safeguard. Should not reach else
}
}
Loading

0 comments on commit ab23c6b

Please sign in to comment.