Compare commits
115 Commits
md/clarify
...
test-pytho
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39a68def7e | ||
|
|
3da641ba00 | ||
|
|
91247c3be2 | ||
|
|
ed4f399a01 | ||
|
|
a291c967c7 | ||
|
|
4f1dec3ce8 | ||
|
|
f6347c776d | ||
|
|
ae3c2c7a2a | ||
|
|
3a985e46a2 | ||
|
|
ba2ae00dac | ||
|
|
0df6adcc51 | ||
|
|
87c2392827 | ||
|
|
eda355803b | ||
|
|
e6df0dc8c5 | ||
|
|
7fac1f9d24 | ||
|
|
4bc3b6484b | ||
|
|
ee29d769a0 | ||
|
|
013974b705 | ||
|
|
202290121b | ||
|
|
5766d22bce | ||
|
|
7b500427ec | ||
|
|
d0a36c3d7c | ||
|
|
ef7ee34349 | ||
|
|
24d6fc3d7d | ||
|
|
e00705930f | ||
|
|
a85b69ce0d | ||
|
|
dff77917f4 | ||
|
|
c25d90382f | ||
|
|
42d8c14a8e | ||
|
|
6b37f5f3af | ||
|
|
809503986a | ||
|
|
b551d305fc | ||
|
|
b24f87f5d1 | ||
|
|
9c95e9b8a7 | ||
|
|
8e8480fc95 | ||
|
|
abf5f8caa8 | ||
|
|
d13f7355c5 | ||
|
|
e560d95105 | ||
|
|
e7828cc2f8 | ||
|
|
0af03be7cc | ||
|
|
26915a1feb | ||
|
|
8fcd5d3839 | ||
|
|
39cefbab7f | ||
|
|
e002ff8980 | ||
|
|
95bb56b5e8 | ||
|
|
1e03d56dac | ||
|
|
a5d21e9623 | ||
|
|
3606567fc8 | ||
|
|
a3b380ca00 | ||
|
|
59ec9091d8 | ||
|
|
158ce159f7 | ||
|
|
a6a737b33b | ||
|
|
af75705b3c | ||
|
|
f904f950ba | ||
|
|
64a8ec3593 | ||
|
|
39e2e551dc | ||
|
|
7606803604 | ||
|
|
7fd1c79dad | ||
|
|
1a7017612c | ||
|
|
4420d0c963 | ||
|
|
3a00a330bc | ||
|
|
22211b18e8 | ||
|
|
bc5bcff995 | ||
|
|
27456e9669 | ||
|
|
13854b5c49 | ||
|
|
143ed4974c | ||
|
|
bca7756059 | ||
|
|
186aa9e644 | ||
|
|
1c92dfb462 | ||
|
|
9cae2a5080 | ||
|
|
127b23d986 | ||
|
|
62917d13bc | ||
|
|
47720ac7dc | ||
|
|
003c9e63b3 | ||
|
|
5c9d18b96a | ||
|
|
054c0477c0 | ||
|
|
c8da77672c | ||
|
|
7360cd88b6 | ||
|
|
55c789fa26 | ||
|
|
a93959a4c5 | ||
|
|
0e1bfe79f2 | ||
|
|
1efe3a5675 | ||
|
|
6ecbad3323 | ||
|
|
8ece3416e3 | ||
|
|
c360c2f457 | ||
|
|
63b43a98d5 | ||
|
|
d4f385694f | ||
|
|
a3d7c7e9f3 | ||
|
|
96ab264d7a | ||
|
|
59ebd75cec | ||
|
|
1ff9264e4c | ||
|
|
692acf2531 | ||
|
|
e0d12b32e4 | ||
|
|
b47e6f4313 | ||
|
|
bdd9dccdc9 | ||
|
|
35d5f824f9 | ||
|
|
c99b613fb3 | ||
|
|
5fc1f7309f | ||
|
|
b02eb58a79 | ||
|
|
337f5bb867 | ||
|
|
958390151e | ||
|
|
09377ed5e9 | ||
|
|
8334d3d91b | ||
|
|
66f7eb7039 | ||
|
|
d228328a29 | ||
|
|
cd8d1a8348 | ||
|
|
0c0b1f7a4b | ||
|
|
4e168d7e8c | ||
|
|
8b92275ceb | ||
|
|
9f04f3ce31 | ||
|
|
3ce8af616d | ||
|
|
577aa9ee51 | ||
|
|
5b9d0ce9fd | ||
|
|
e800c0673d | ||
|
|
5a7469143b |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -68,6 +68,7 @@ cypress.env.json
|
||||
/resources/frontend_client/embed-sdk.html
|
||||
/resources/frontend_client/index.html
|
||||
/resources/frontend_client/public.html
|
||||
/resources/frontend_client/app/assets/pyodide/
|
||||
/resources/embedding-sdk
|
||||
/resources/i18n/*.edn
|
||||
/resources/instance_analytics.zip
|
||||
|
||||
48
bin/download-pyodide.sh
Executable file
48
bin/download-pyodide.sh
Executable file
@@ -0,0 +1,48 @@
|
||||
#! /usr/bin/env bash
|
||||
|
||||
DEST_DIR="resources/frontend_client/app/assets"
|
||||
|
||||
REQUIRED_FILES=(
|
||||
"pyodide/pyodide-lock.json"
|
||||
"pyodide/pyodide.asm.js"
|
||||
"pyodide/pyodide.asm.wasm"
|
||||
"pyodide/pyodide.js"
|
||||
|
||||
"pyodide/python_stdlib.zip"
|
||||
|
||||
"pyodide/micropip-0.10.1-py3-none-any.whl"
|
||||
"pyodide/numpy-2.2.5-cp313-cp313-pyodide_2025_0_wasm32.whl"
|
||||
"pyodide/pandas-2.3.1-cp313-cp313-pyodide_2025_0_wasm32.whl"
|
||||
"pyodide/python_dateutil-2.9.0.post0-py2.py3-none-any.whl"
|
||||
"pyodide/pytz-2025.2-py2.py3-none-any.whl"
|
||||
"pyodide/packaging-24.2-py3-none-any.whl"
|
||||
"pyodide/six-1.17.0-py2.py3-none-any.whl"
|
||||
)
|
||||
|
||||
PYODIDE_VERSION="0.28.3"
|
||||
DOWNLOAD_URL="https://github.com/pyodide/pyodide/releases/download/${PYODIDE_VERSION}/pyodide-${PYODIDE_VERSION}.tar.bz2"
|
||||
|
||||
function all_files_exist() {
|
||||
for FILE in "${REQUIRED_FILES[@]}"; do
|
||||
TEST_FILE="$DEST_DIR/$FILE"
|
||||
|
||||
if [ ! -f "$TEST_FILE" ]; then
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
if all_files_exist; then
|
||||
echo "Pyodide files already exist, skipping download"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Downloading Pyodide $PYODIDE_VERSION into $DEST_DIR"
|
||||
echo "This might take a while"
|
||||
|
||||
rm -rf "$DEST_DIR/pyodide"
|
||||
mkdir -p "$DEST_DIR"
|
||||
|
||||
curl -Ls "$DOWNLOAD_URL" | tar xvfj - -C "$DEST_DIR" ${REQUIRED_FILES[@]}
|
||||
@@ -1730,11 +1730,12 @@ LIMIT
|
||||
|
||||
H.PythonEditor.clear().type(
|
||||
dedent`
|
||||
import pandas as pd
|
||||
import pandas as pd
|
||||
|
||||
def transform():
|
||||
return pd.DataFrame([{"foo": common.useful_calculation(1, 2)}])
|
||||
`,
|
||||
def transform():
|
||||
return pd.DataFrame([{"foo": common.useful_calculation(1, 2)}])
|
||||
`,
|
||||
{ allowFastSet: true },
|
||||
);
|
||||
|
||||
getQueryEditor().findByText("Import common library").click();
|
||||
@@ -1746,6 +1747,8 @@ LIMIT
|
||||
|
||||
H.popover().findByText(DB_NAME).click();
|
||||
|
||||
cy.findByTestId("python-data-picker").should("not.be.disabled");
|
||||
|
||||
cy.findByTestId("python-data-picker")
|
||||
.findByText("Select a table…")
|
||||
.click();
|
||||
@@ -2679,6 +2682,132 @@ describe("scenarios > admin > transforms > runs", () => {
|
||||
});
|
||||
});
|
||||
|
||||
H.describeWithSnowplowEE(
|
||||
"scenarios > admin > transforms > python runner",
|
||||
() => {
|
||||
beforeEach(() => {
|
||||
H.restore("postgres-writable");
|
||||
H.resetTestTable({ type: "postgres", table: "many_schemas" });
|
||||
H.resetSnowplow();
|
||||
cy.signInAsAdmin();
|
||||
H.activateToken("bleeding-edge");
|
||||
H.resyncDatabase({ dbId: WRITABLE_DB_ID, tableName: SOURCE_TABLE });
|
||||
|
||||
setPythonRunnerSettings();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
H.expectNoBadSnowplowEvents();
|
||||
});
|
||||
|
||||
it("should be possible to test run a Python script", () => {
|
||||
H.getTableId({ name: "Animals", databaseId: WRITABLE_DB_ID }).then(
|
||||
(id) => {
|
||||
createPythonLibrary(
|
||||
"common.py",
|
||||
dedent`
|
||||
def useful_calculation(a, b):
|
||||
return a + b
|
||||
`,
|
||||
);
|
||||
|
||||
createPythonTransform({
|
||||
body: dedent`
|
||||
import pandas as pd
|
||||
import common
|
||||
|
||||
|
||||
def transform(animals):
|
||||
print("Hello, world!")
|
||||
return pd.DataFrame([{"foo": common.useful_calculation(40, 2), "avg_score": animals["score"].agg("mean") }])
|
||||
`,
|
||||
sourceTables: { animals: id },
|
||||
visitTransform: true,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
getTransformPage().findByText("Edit script").click();
|
||||
|
||||
function testInPyodide() {
|
||||
cy.log("running the script in Pyodide should work");
|
||||
runPythonScriptAndWaitForSuccess();
|
||||
H.assertTableData({
|
||||
columns: ["foo", "avg_score"],
|
||||
firstRows: [["42", "20"]],
|
||||
});
|
||||
assertLogs("Hello, world!");
|
||||
|
||||
cy.log("updating the common library should affect the results");
|
||||
createPythonLibrary(
|
||||
"common.py",
|
||||
dedent`
|
||||
def useful_calculation(a, b):
|
||||
return a + b + 1
|
||||
`,
|
||||
);
|
||||
assertLogs("Hello, world!");
|
||||
|
||||
runPythonScriptAndWaitForSuccess();
|
||||
H.assertTableData({
|
||||
columns: ["foo", "avg_score"],
|
||||
firstRows: [["43", "20"]],
|
||||
});
|
||||
assertLogs("Hello, world!");
|
||||
}
|
||||
|
||||
function testInTestRunner() {
|
||||
cy.log("running the script in the test runner should work");
|
||||
createPythonLibrary(
|
||||
"common.py",
|
||||
dedent`
|
||||
def useful_calculation(a, b):
|
||||
return a + b + 2
|
||||
`,
|
||||
);
|
||||
|
||||
getQueryEditor().findByText("Emulate runner in-browser").click();
|
||||
|
||||
runPythonScriptAndWaitForSuccess();
|
||||
H.assertTableData({
|
||||
columns: ["foo", "avg_score"],
|
||||
firstRows: [["44", "20"]],
|
||||
});
|
||||
assertLogs("Hello, world!");
|
||||
|
||||
cy.log("updating the common library should affect the results");
|
||||
createPythonLibrary(
|
||||
"common.py",
|
||||
dedent`
|
||||
def useful_calculation(a, b):
|
||||
return a + b + 3
|
||||
`,
|
||||
);
|
||||
|
||||
runPythonScriptAndWaitForSuccess();
|
||||
H.assertTableData({
|
||||
columns: ["foo", "avg_score"],
|
||||
firstRows: [["45", "20"]],
|
||||
});
|
||||
assertLogs("Hello, world!");
|
||||
}
|
||||
|
||||
testInPyodide();
|
||||
testInTestRunner();
|
||||
});
|
||||
|
||||
function assertLogs(expected: string) {
|
||||
getQueryEditor().findByText("Output").click();
|
||||
|
||||
cy.findByTestId("python-logs")
|
||||
.should("be.visible")
|
||||
.should("contain", expected);
|
||||
|
||||
getQueryEditor().findByText("Results").click();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function getTransformListPage() {
|
||||
return cy.findByTestId("transform-list-page");
|
||||
}
|
||||
@@ -2957,3 +3086,13 @@ function setPythonRunnerSettings() {
|
||||
);
|
||||
H.updateEnterpriseSetting("python-storage-s-3-path-style-access", true);
|
||||
}
|
||||
|
||||
function runPythonScriptAndWaitForSuccess() {
|
||||
getQueryEditor().findByTestId("run-button").click();
|
||||
|
||||
getQueryEditor()
|
||||
.findByTestId("loading-indicator", { timeout: 60000 })
|
||||
.should("not.exist");
|
||||
|
||||
cy.findByTestId("python-results").should("be.visible");
|
||||
}
|
||||
|
||||
@@ -29,5 +29,8 @@ export const pythonLibraryApi = EnterpriseApi.injectEndpoints({
|
||||
}),
|
||||
});
|
||||
|
||||
export const { useGetPythonLibraryQuery, useUpdatePythonLibraryMutation } =
|
||||
pythonLibraryApi;
|
||||
export const {
|
||||
useGetPythonLibraryQuery,
|
||||
useLazyGetPythonLibraryQuery,
|
||||
useUpdatePythonLibraryMutation,
|
||||
} = pythonLibraryApi;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type {
|
||||
ExecutePythonTransformRequest,
|
||||
ExecutePythonTransformResponse,
|
||||
TestPythonTransformRequest,
|
||||
TestPythonTransformResponse,
|
||||
} from "metabase-types/api";
|
||||
|
||||
import { EnterpriseApi } from "./api";
|
||||
@@ -8,27 +8,20 @@ import { EnterpriseApi } from "./api";
|
||||
export const pythonRunnerApi = EnterpriseApi.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
executePython: builder.mutation<
|
||||
ExecutePythonTransformResponse,
|
||||
ExecutePythonTransformRequest
|
||||
TestPythonTransformResponse,
|
||||
TestPythonTransformRequest
|
||||
>({
|
||||
query: ({ code, tables }) => ({
|
||||
url: "/api/ee/transform/test-python",
|
||||
query: ({ code, source_tables }) => ({
|
||||
url: "/api/ee/transforms-python/test-run",
|
||||
method: "POST",
|
||||
body: {
|
||||
code,
|
||||
tables,
|
||||
source_tables,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
cancelPython: builder.mutation<void, void>({
|
||||
query: () => ({
|
||||
url: "/api/ee/transform/test-python/cancel",
|
||||
method: "POST",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
overrideExisting: true,
|
||||
});
|
||||
|
||||
export const { useExecutePythonMutation, useCancelPythonMutation } =
|
||||
pythonRunnerApi;
|
||||
export const { useExecutePythonMutation } = pythonRunnerApi;
|
||||
|
||||
@@ -8,6 +8,7 @@ import RunButtonWithTooltip from "metabase/query_builder/components/RunButtonWit
|
||||
import { Box, Button, Checkbox, Flex, Icon, Stack, Tooltip } from "metabase/ui";
|
||||
import { SHARED_LIB_IMPORT_PATH } from "metabase-enterprise/transforms-python/constants";
|
||||
|
||||
// Pyodide status is now handled in the Web Worker
|
||||
import { PythonEditor } from "../../PythonEditor";
|
||||
|
||||
import S from "./PythonEditorBody.module.css";
|
||||
|
||||
@@ -1,23 +1,34 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { DataGrid, useDataGridInstance } from "metabase/data-grid";
|
||||
import { formatValue } from "metabase/lib/formatting";
|
||||
import type { PythonTransformResultData } from "metabase-enterprise/transforms-python/hooks/use-test-python-transform";
|
||||
import type { RowValue } from "metabase-types/api";
|
||||
|
||||
import { type Row, parseOutput } from "./utils";
|
||||
export type Row = Record<string, RowValue>;
|
||||
|
||||
export function ExecutionOutputTable({ output }: { output?: string }) {
|
||||
const { headers, rows } = useMemo(() => parseOutput(output ?? ""), [output]);
|
||||
export function ExecutionOutputTable({
|
||||
output,
|
||||
}: {
|
||||
output?: PythonTransformResultData;
|
||||
}) {
|
||||
const { cols = [], rows = [] } = output ?? {};
|
||||
|
||||
const tableProps = useDataGridInstance<Row, unknown>({
|
||||
data: rows,
|
||||
columnsOptions: headers.map((header) => ({
|
||||
id: header,
|
||||
name: header,
|
||||
accessorFn: (row) => row[header],
|
||||
columnsOptions: cols.map((column) => ({
|
||||
id: column.name,
|
||||
name: column.name,
|
||||
accessorFn: (row) => row[column.name],
|
||||
formatter: (value) => {
|
||||
return formatValue(value, {
|
||||
type: "cell",
|
||||
jsx: true,
|
||||
rich: true,
|
||||
});
|
||||
},
|
||||
})),
|
||||
minGridWidth: 100,
|
||||
});
|
||||
|
||||
if (!output || headers.length === 0) {
|
||||
if (!output || cols.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
.header {
|
||||
border-top: 1px solid var(--mb-color-border);
|
||||
border-bottom: 1px solid var(--mb-color-border);
|
||||
}
|
||||
|
||||
.logs {
|
||||
white-space: pre;
|
||||
border: 1px solid var(--mb-color-border);
|
||||
font-family: var(--mb-default-monospace-font-family);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.visualization {
|
||||
@@ -15,10 +20,6 @@
|
||||
border-radius: var(--mantine-radius-md);
|
||||
}
|
||||
|
||||
.empty {
|
||||
border-top: 1px solid var(--mb-color-border);
|
||||
}
|
||||
|
||||
.tableHeader {
|
||||
background-color: var(--mb-color-bg-light);
|
||||
padding: var(--mantine-spacing-sm);
|
||||
@@ -29,23 +30,6 @@
|
||||
padding: var(--mantine-spacing-sm);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--mb-color-error);
|
||||
background-color: var(--mb-color-background-error);
|
||||
border-bottom: 1px solid var(--mb-color-border);
|
||||
}
|
||||
|
||||
.info {
|
||||
color: var(--mb-color-text-dark);
|
||||
border: 1px solid var(--mb-color-bg-medium);
|
||||
background-color: var(--mb-color-bg-light);
|
||||
}
|
||||
|
||||
.success {
|
||||
color: var(--mb-color-success);
|
||||
border-bottom: 1px solid var(--mb-color-border);
|
||||
}
|
||||
|
||||
.loading {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -54,3 +38,13 @@
|
||||
right: 0;
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.error {
|
||||
background-color: var(--mb-color-bg-error);
|
||||
}
|
||||
|
||||
.traceback {
|
||||
white-space: pre;
|
||||
font-family: var(--mb-default-monospace-font-family);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@@ -1,42 +1,102 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import { c, t } from "ttag";
|
||||
|
||||
import EmptyCodeResult from "assets/img/empty-states/code.svg";
|
||||
import { AnsiLogs } from "metabase/common/components/AnsiLogs";
|
||||
import DebouncedFrame from "metabase/common/components/DebouncedFrame";
|
||||
import { LoadingSpinner } from "metabase/common/components/MetadataInfo/MetadataInfo.styled";
|
||||
import { isMac } from "metabase/lib/browser";
|
||||
import { Box, Flex, Icon, Stack, Text, Title } from "metabase/ui";
|
||||
|
||||
import type { ExecutionResult } from "../utils";
|
||||
import { Box, Flex, Group, Icon, Stack, Switch, Tabs, Text } from "metabase/ui";
|
||||
import type { PythonTransformResultData } from "metabase-enterprise/transforms-python/hooks/use-test-python-transform";
|
||||
import type { PythonExecutionResult } from "metabase-enterprise/transforms-python/services/pyodide-worker";
|
||||
|
||||
import { ExecutionOutputTable } from "./ExecutionOutputTable";
|
||||
import S from "./PythonEditorResults.module.css";
|
||||
|
||||
type PythonEditorProps = {
|
||||
type PythonResultsProps = {
|
||||
isRunning?: boolean;
|
||||
executionResult?: ExecutionResult | null;
|
||||
executionResult?: PythonExecutionResult<PythonTransformResultData> | null;
|
||||
testRunner: "pyodide" | "api";
|
||||
onTestRunnerChange: (runner: "pyodide" | "api") => void;
|
||||
};
|
||||
|
||||
type ResultsTab = "results" | "output";
|
||||
|
||||
export function PythonEditorResults({
|
||||
executionResult,
|
||||
isRunning,
|
||||
}: PythonEditorProps) {
|
||||
testRunner,
|
||||
onTestRunnerChange,
|
||||
}: PythonResultsProps) {
|
||||
const [tab, setTab] = useState<ResultsTab>("results");
|
||||
return (
|
||||
<DebouncedFrame className={S.visualization}>
|
||||
<ExecutionResultHeader executionResult={executionResult} />
|
||||
<ExecutionResults executionResult={executionResult} />
|
||||
{isRunning && <LoadingState />}
|
||||
<Stack data-testid="python-results" gap={0} h="100%">
|
||||
<ExecutionResultHeader
|
||||
executionResult={executionResult}
|
||||
tab={tab}
|
||||
onTabChange={setTab}
|
||||
testRunner={testRunner}
|
||||
onTestRunnerChange={onTestRunnerChange}
|
||||
/>
|
||||
{!executionResult && <EmptyState />}
|
||||
{executionResult &&
|
||||
tab === "results" &&
|
||||
(executionResult?.error ? (
|
||||
<ErrorState error={executionResult.error.message} />
|
||||
) : (
|
||||
<ExecutionOutputTable output={executionResult?.output} />
|
||||
))}
|
||||
{executionResult && tab === "output" && (
|
||||
<ExecutionOutputLogs executionResult={executionResult} />
|
||||
)}
|
||||
{isRunning && <LoadingState />}
|
||||
</Stack>
|
||||
</DebouncedFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function ExecutionResultHeader({
|
||||
executionResult,
|
||||
tab,
|
||||
onTabChange,
|
||||
testRunner,
|
||||
onTestRunnerChange,
|
||||
}: {
|
||||
executionResult?: ExecutionResult | null;
|
||||
executionResult?: PythonExecutionResult | null;
|
||||
tab: ResultsTab;
|
||||
onTabChange: (tab: ResultsTab) => void;
|
||||
testRunner: "pyodide" | "api";
|
||||
onTestRunnerChange: (runner: "pyodide" | "api") => void;
|
||||
}) {
|
||||
const message = getMessageForExecutionResult(executionResult);
|
||||
return <Flex w="100%">{message}</Flex>;
|
||||
|
||||
return (
|
||||
<Group className={S.header} justify="space-between">
|
||||
<Group align="center">
|
||||
<Box mt="xs">
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={(value) => {
|
||||
if (value) {
|
||||
onTabChange(value as ResultsTab);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="results">{t`Results`}</Tabs.Tab>
|
||||
<Tabs.Tab value="output">{t`Output`}</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
</Box>
|
||||
{message}
|
||||
</Group>
|
||||
<RunnerToggle
|
||||
testRunner={testRunner}
|
||||
onTestRunnerChange={onTestRunnerChange}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function getRunQueryShortcut() {
|
||||
@@ -55,7 +115,7 @@ function EmptyState() {
|
||||
const keyboardShortcut = getRunQueryShortcut();
|
||||
|
||||
return (
|
||||
<Flex h="100%" align="center" justify="center" className={S.empty}>
|
||||
<Flex h="100%" align="center" justify="center">
|
||||
<Stack maw="25rem" gap={0} ta="center" align="center">
|
||||
<Box maw="3rem" mb="0.75rem">
|
||||
<img src={EmptyCodeResult} alt="Code prompt icon" />
|
||||
@@ -71,105 +131,89 @@ function EmptyState() {
|
||||
);
|
||||
}
|
||||
|
||||
function ExecutionResults({
|
||||
executionResult,
|
||||
}: {
|
||||
executionResult?: ExecutionResult | null;
|
||||
}) {
|
||||
if (!executionResult) {
|
||||
return <EmptyState />;
|
||||
}
|
||||
|
||||
function ErrorState({ error }: { error: string }) {
|
||||
return (
|
||||
<Stack gap={0}>
|
||||
<ExecutionLogs
|
||||
label={t`Standard output:`}
|
||||
content={executionResult.stdout}
|
||||
/>
|
||||
<ExecutionLogs
|
||||
label={t`Standard error:`}
|
||||
content={executionResult.stderr}
|
||||
/>
|
||||
|
||||
<ExecutionOutputTable output={executionResult.output} />
|
||||
<Stack gap="sm" h="100%" p="md" c="error" className={S.error}>
|
||||
<Group fw="bold" gap="sm">
|
||||
<Icon name="warning" />
|
||||
{t`Error`}
|
||||
</Group>
|
||||
<Box className={S.traceback} fz="sm">
|
||||
{error}
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function getMessageForExecutionResult(
|
||||
executionResult?: ExecutionResult | null,
|
||||
executionResult?: PythonExecutionResult | null,
|
||||
) {
|
||||
if (!executionResult) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (executionResult.error) {
|
||||
return (
|
||||
<Box className={S.error} p="sm" w="100%">
|
||||
<Flex gap="sm">
|
||||
<Icon name="warning" />
|
||||
<Stack gap={0}>
|
||||
<Title order={5} mb="xs">
|
||||
{t`An error occurred while executing your Python script`}
|
||||
</Title>
|
||||
{executionResult.error}
|
||||
</Stack>
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!executionResult.output) {
|
||||
return (
|
||||
<Flex className={S.info} p="sm" gap="sm" align="center" w="100%">
|
||||
<Icon name="info" />
|
||||
{t`No results to display.`}
|
||||
<Flex gap="sm" align="center" pr="md" c="error">
|
||||
<Icon name="warning" />
|
||||
{t`An error occurred while executing your Python script`}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex className={S.success} p="sm" gap="sm" align="center" w="100%">
|
||||
<Icon name="check" />
|
||||
{t`Script executed successfully.`}
|
||||
<Flex gap="sm" align="center" pr="md" c="success">
|
||||
<Icon name="check_filled" />
|
||||
{t`Script executed successfully`}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
function ExecutionLogs({
|
||||
label,
|
||||
content,
|
||||
function ExecutionOutputLogs({
|
||||
executionResult,
|
||||
}: {
|
||||
label: string;
|
||||
content?: string | null;
|
||||
}) {
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title={label}>
|
||||
<Box p="sm" bg="bg-light" mah="150px" bdrs="xs" className={S.logs}>
|
||||
{content}
|
||||
</Box>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: ReactNode;
|
||||
children: ReactNode;
|
||||
executionResult: PythonExecutionResult;
|
||||
}) {
|
||||
return (
|
||||
<Box>
|
||||
{title && (
|
||||
<Title order={5} mb="xs">
|
||||
{title}
|
||||
</Title>
|
||||
<Box
|
||||
fz="sm"
|
||||
p="md"
|
||||
bg="bg-light"
|
||||
h="100%"
|
||||
className={S.logs}
|
||||
data-testid="python-logs"
|
||||
>
|
||||
{executionResult.logs ? (
|
||||
<AnsiLogs>{executionResult.logs}</AnsiLogs>
|
||||
) : (
|
||||
<Text
|
||||
c="text-light"
|
||||
fz="sm"
|
||||
fs="italic"
|
||||
>{t`No output to display`}</Text>
|
||||
)}
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function RunnerToggle({
|
||||
testRunner,
|
||||
onTestRunnerChange,
|
||||
}: {
|
||||
testRunner: "pyodide" | "api";
|
||||
onTestRunnerChange: (runner: "pyodide" | "api") => void;
|
||||
}) {
|
||||
return (
|
||||
<Group align="center" pr="md" c="text-light">
|
||||
<Switch
|
||||
size="xs"
|
||||
label={t`Emulate runner in-browser`}
|
||||
labelPosition="left"
|
||||
checked={testRunner === "pyodide"}
|
||||
onChange={({ target }) => {
|
||||
onTestRunnerChange(target.checked ? "pyodide" : "api");
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import type { RowValue } from "metabase-types/api";
|
||||
|
||||
export type Row = Record<string, RowValue>;
|
||||
|
||||
// Parses the output of a Python transform (given as JSON-lines)
|
||||
// into a set of headers and rows.
|
||||
//
|
||||
// TODO: The server should return a non-stringified JSON array of rows,
|
||||
// not a string.
|
||||
export function parseOutput(output: string): {
|
||||
headers: string[];
|
||||
rows: Row[];
|
||||
} {
|
||||
if (!output?.trim()) {
|
||||
return { headers: [], rows: [] };
|
||||
}
|
||||
|
||||
const lines = output.trim().split("\n");
|
||||
const headers = new Set<string>();
|
||||
const rows: Row[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const data = JSON.parse(line) as Row;
|
||||
for (const key in data) {
|
||||
headers.add(key);
|
||||
}
|
||||
rows.push(data);
|
||||
} catch (err) {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
headers: Array.from(headers),
|
||||
rows,
|
||||
};
|
||||
}
|
||||
@@ -15,12 +15,11 @@ import type {
|
||||
import { PythonDataPicker } from "./PythonDataPicker";
|
||||
import { PythonEditorBody } from "./PythonEditorBody";
|
||||
import { PythonEditorResults } from "./PythonEditorResults";
|
||||
import { useTestPythonTransform } from "./hooks";
|
||||
import {
|
||||
getValidationResult,
|
||||
isPythonTransformSource,
|
||||
updateTransformSignature,
|
||||
useShouldShowPythonDebugger,
|
||||
useTestPythonTransform,
|
||||
} from "./utils";
|
||||
|
||||
export type PythonTransformEditorProps = {
|
||||
@@ -54,10 +53,13 @@ export function PythonTransformEditor({
|
||||
const saveSource = proposedSource ?? source;
|
||||
const [isSourceDirty, setIsSourceDirty] = useState(false);
|
||||
|
||||
useRegisterMetabotTransformContext(transform, proposedSource ?? source);
|
||||
const [testRunner, setTestRunner] = useState<"pyodide" | "api">("pyodide");
|
||||
|
||||
const { isRunning, isDirty, cancel, run, executionResult } =
|
||||
useTestPythonTransform(source);
|
||||
const { isRunning, cancel, run, executionResult } = useTestPythonTransform(
|
||||
source,
|
||||
testRunner,
|
||||
);
|
||||
useRegisterMetabotTransformContext(transform, proposedSource ?? source);
|
||||
|
||||
const handleScriptChange = (body: string) => {
|
||||
const newSource = {
|
||||
@@ -105,12 +107,7 @@ export function PythonTransformEditor({
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const showDebugger = useShouldShowPythonDebugger();
|
||||
|
||||
const handleCmdEnter = () => {
|
||||
if (!showDebugger) {
|
||||
return;
|
||||
}
|
||||
if (isRunning) {
|
||||
cancel();
|
||||
} else if (isRunnable && isPythonTransformSource(saveSource)) {
|
||||
@@ -149,22 +146,22 @@ export function PythonTransformEditor({
|
||||
<PythonEditorBody
|
||||
isRunnable={isRunnable && isPythonTransformSource(source)}
|
||||
isRunning={isRunning}
|
||||
isDirty={isDirty}
|
||||
isDirty
|
||||
onRun={run}
|
||||
onCancel={cancel}
|
||||
source={source.body}
|
||||
proposedSource={proposedSource?.body}
|
||||
onChange={handleScriptChange}
|
||||
withDebugger={showDebugger}
|
||||
withDebugger
|
||||
onAcceptProposed={handleAcceptProposed}
|
||||
onRejectProposed={onRejectProposed}
|
||||
/>
|
||||
{showDebugger && (
|
||||
<PythonEditorResults
|
||||
isRunning={isRunning}
|
||||
executionResult={executionResult}
|
||||
/>
|
||||
)}
|
||||
<PythonEditorResults
|
||||
isRunning={isRunning}
|
||||
executionResult={executionResult}
|
||||
testRunner={testRunner}
|
||||
onTestRunnerChange={setTestRunner}
|
||||
/>
|
||||
</Stack>
|
||||
</Flex>
|
||||
</Stack>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useRef } from "react";
|
||||
|
||||
import { useExecutePythonMutation } from "metabase-enterprise/api/transform-python";
|
||||
import { useTestPythonTransformOnPyodide } from "metabase-enterprise/transforms-python/hooks/use-test-python-transform";
|
||||
import type {
|
||||
PythonTransformSourceDraft,
|
||||
TestPythonTransformResponse,
|
||||
} from "metabase-types/api";
|
||||
|
||||
type TestPythonTransform = {
|
||||
isRunning: boolean;
|
||||
run: () => void;
|
||||
cancel: () => void;
|
||||
executionResult?: TestPythonTransformResponse | null;
|
||||
};
|
||||
|
||||
export function useTestPythonTransform(
|
||||
source: PythonTransformSourceDraft,
|
||||
mode: "pyodide" | "api",
|
||||
): TestPythonTransform {
|
||||
const pyodide = useTestPythonTransformOnPyodide(source);
|
||||
const api = useTestPythonTransformOnRunner(source);
|
||||
|
||||
function run() {
|
||||
if (mode === "pyodide") {
|
||||
return pyodide.run();
|
||||
} else {
|
||||
return api.run();
|
||||
}
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
pyodide.cancel();
|
||||
}
|
||||
|
||||
return {
|
||||
run,
|
||||
cancel,
|
||||
isRunning: mode === "pyodide" ? pyodide.isRunning : api.isRunning,
|
||||
executionResult:
|
||||
mode === "pyodide" ? pyodide.executionResult : api.executionResult,
|
||||
};
|
||||
}
|
||||
|
||||
function useTestPythonTransformOnRunner(source: PythonTransformSourceDraft) {
|
||||
const [fetch, { data: executionResult, isLoading }] =
|
||||
useExecutePythonMutation();
|
||||
|
||||
const abortionRef = useRef<(() => void) | null>(null);
|
||||
|
||||
async function run() {
|
||||
const request = fetch({
|
||||
code: source.body,
|
||||
source_tables: source["source-tables"],
|
||||
});
|
||||
|
||||
abortionRef.current = () => request.abort();
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
abortionRef.current?.();
|
||||
}
|
||||
|
||||
return {
|
||||
run,
|
||||
cancel,
|
||||
isRunning: isLoading,
|
||||
executionResult,
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,6 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useLocation } from "react-use";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { getErrorMessage } from "metabase/api/utils";
|
||||
import { useExecutePythonMutation } from "metabase-enterprise/api/transform-python";
|
||||
import type {
|
||||
ExecutePythonTransformResponse,
|
||||
PythonTransformSource,
|
||||
PythonTransformSourceDraft,
|
||||
PythonTransformTableAliases,
|
||||
@@ -148,83 +143,12 @@ ${tableAliases
|
||||
return script + functionTemplate;
|
||||
}
|
||||
|
||||
export type ExecutionResult = {
|
||||
output?: string;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type TestPythonScriptState = {
|
||||
isRunning: boolean;
|
||||
isDirty: boolean;
|
||||
executionResult: ExecutionResult | null;
|
||||
run: () => void;
|
||||
cancel: () => void;
|
||||
};
|
||||
|
||||
export function useTestPythonTransform(
|
||||
source: PythonTransformSourceDraft,
|
||||
): TestPythonScriptState {
|
||||
const [executePython, { isLoading: isRunning, originalArgs }] =
|
||||
useExecutePythonMutation();
|
||||
const abort = useRef<(() => void) | null>(null);
|
||||
const [executionResult, setData] =
|
||||
useState<ExecutePythonTransformResponse | null>(null);
|
||||
|
||||
const isDirty = originalArgs?.code !== source.body;
|
||||
|
||||
const run = async () => {
|
||||
if (source["source-database"] === undefined) {
|
||||
return null;
|
||||
}
|
||||
const request = executePython({
|
||||
code: source.body,
|
||||
tables: source["source-tables"],
|
||||
});
|
||||
abort.current = () => request.abort();
|
||||
|
||||
try {
|
||||
const data = await request.unwrap();
|
||||
setData(data);
|
||||
} catch (error) {
|
||||
if (typeof error === "object" && error !== null) {
|
||||
if ("name" in error && error.name === "AbortError") {
|
||||
setData({ error: t`Python script execution was canceled` });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const errorMessage = getErrorMessage(error, t`An unknown error occurred`);
|
||||
setData({ error: errorMessage });
|
||||
}
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
abort.current?.();
|
||||
};
|
||||
|
||||
return {
|
||||
isRunning,
|
||||
isDirty,
|
||||
cancel,
|
||||
run,
|
||||
executionResult,
|
||||
};
|
||||
}
|
||||
|
||||
export function isPythonTransformSource(
|
||||
source: PythonTransformSourceDraft,
|
||||
): source is PythonTransformSource {
|
||||
return source.type === "python" && source["source-database"] !== undefined;
|
||||
}
|
||||
|
||||
export function useShouldShowPythonDebugger() {
|
||||
const location = useLocation();
|
||||
const params = new URLSearchParams(location.search);
|
||||
return params.get("debugger") === "1";
|
||||
}
|
||||
|
||||
export function getValidationResult(source: PythonTransformSourceDraft) {
|
||||
if (!source["source-database"]) {
|
||||
return { isValid: false, errorMessage: t`Select a source a database` };
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useCallback } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { useLazyGetAdhocQueryQuery } from "metabase/api";
|
||||
import { getErrorMessage } from "metabase/api/utils";
|
||||
import {
|
||||
getDataErrorMessage,
|
||||
is403Error,
|
||||
} from "metabase/metadata/pages/DataModel/components/PreviewSection/utils";
|
||||
import type {
|
||||
DatabaseId,
|
||||
DatasetData,
|
||||
DatasetQuery,
|
||||
PythonTransformSourceDraft,
|
||||
RowValue,
|
||||
TableId,
|
||||
} from "metabase-types/api";
|
||||
|
||||
export type JSONRow = Record<string, RowValue>;
|
||||
|
||||
export type SampleData = {
|
||||
alias: string;
|
||||
rows: JSONRow[];
|
||||
};
|
||||
|
||||
export function useSampleData(source: PythonTransformSourceDraft) {
|
||||
const [fetchData, { isLoading }] = useLazyGetAdhocQueryQuery();
|
||||
|
||||
const fetchSampleData = useCallback(async () => {
|
||||
const databaseId = source["source-database"];
|
||||
const promises = [];
|
||||
|
||||
if (!databaseId) {
|
||||
throw new Error(t`No database selected`);
|
||||
}
|
||||
|
||||
for (const alias in source["source-tables"]) {
|
||||
const tableId = source["source-tables"][alias];
|
||||
const promise = fetchData(getPreviewQuery(databaseId, tableId), true)
|
||||
.unwrap()
|
||||
.then((data) => {
|
||||
if (data.status === "failed") {
|
||||
throw new Error(getDataErrorMessage(data));
|
||||
}
|
||||
return formatData(alias, data.data);
|
||||
});
|
||||
promises.push(promise);
|
||||
}
|
||||
|
||||
try {
|
||||
return await Promise.all(promises);
|
||||
} catch (error) {
|
||||
if (is403Error(error)) {
|
||||
throw new Error(t`Sorry, you don’t have permission to see that.`);
|
||||
}
|
||||
throw new Error(getErrorMessage(error));
|
||||
}
|
||||
}, [source, fetchData]);
|
||||
|
||||
return { fetchSampleData, isRunning: isLoading };
|
||||
}
|
||||
|
||||
function getPreviewQuery(
|
||||
databaseId: DatabaseId,
|
||||
tableId: TableId,
|
||||
): DatasetQuery {
|
||||
return {
|
||||
type: "query",
|
||||
database: databaseId,
|
||||
query: {
|
||||
"source-table": tableId,
|
||||
limit: 50,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function formatData(alias: string, data: DatasetData): SampleData {
|
||||
const rows = data.rows.map((row: RowValue[]) =>
|
||||
Object.fromEntries(
|
||||
data.cols.map((column, index) => [column.name, row[index]]),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
alias,
|
||||
rows,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { useLazyGetPythonLibraryQuery } from "metabase-enterprise/api/python-transform-library";
|
||||
|
||||
import { SHARED_LIB_IMPORT_PATH } from "../constants";
|
||||
import type { PythonLibraries } from "../services/pyodide-worker";
|
||||
|
||||
export function usePythonLibraries() {
|
||||
const [fetchLibrary, { isLoading }] = useLazyGetPythonLibraryQuery();
|
||||
|
||||
const fetchLibraries = useCallback(async (): Promise<PythonLibraries> => {
|
||||
try {
|
||||
const sharedLib = await fetchLibrary({
|
||||
path: SHARED_LIB_IMPORT_PATH,
|
||||
}).unwrap();
|
||||
|
||||
return {
|
||||
[SHARED_LIB_IMPORT_PATH]: sharedLib?.source ?? "",
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
[SHARED_LIB_IMPORT_PATH]: "",
|
||||
};
|
||||
}
|
||||
}, [fetchLibrary]);
|
||||
|
||||
return { fetchLibraries, isRunning: isLoading };
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
import { getErrorMessage } from "metabase/api/utils";
|
||||
|
||||
import {
|
||||
PyodideWorkerPool,
|
||||
type PythonExecutionResult,
|
||||
type PythonLibraries,
|
||||
} from "../services/pyodide-worker";
|
||||
|
||||
export function useRunPython<T = unknown>() {
|
||||
const [pool] = useState(() => new PyodideWorkerPool());
|
||||
const controller = useRef<AbortController | null>(null);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [data, setData] = useState<PythonExecutionResult<T> | null>(null);
|
||||
|
||||
const executePython = async (
|
||||
code: string,
|
||||
libraries: PythonLibraries = {},
|
||||
) => {
|
||||
if (controller.current) {
|
||||
controller.current.abort();
|
||||
}
|
||||
controller.current = new AbortController();
|
||||
const { signal } = controller.current;
|
||||
|
||||
try {
|
||||
setIsRunning(true);
|
||||
const result = await pool.executePython<T>(code, libraries, { signal });
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
setData(result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
setData({ error: { message: getErrorMessage(error) } });
|
||||
} finally {
|
||||
setIsRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
function cancel() {
|
||||
controller.current?.abort();
|
||||
setIsRunning(false);
|
||||
}
|
||||
|
||||
return {
|
||||
isRunning,
|
||||
data,
|
||||
executePython,
|
||||
cancel,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import type { PythonTransformSourceDraft, RowValue } from "metabase-types/api";
|
||||
|
||||
import { type SampleData, useSampleData } from "./use-data-sample";
|
||||
import { usePythonLibraries } from "./use-python-shared-libraries";
|
||||
import { useRunPython } from "./use-run-python";
|
||||
|
||||
export type PyodideTableSource = {
|
||||
database_id: number;
|
||||
variable_name: string;
|
||||
columns: {
|
||||
name: string;
|
||||
type: string;
|
||||
}[];
|
||||
rows: Record<string, RowValue>[];
|
||||
};
|
||||
|
||||
export type PythonTransformResultData = {
|
||||
cols: { name: string }[];
|
||||
rows: Record<string, RowValue>[];
|
||||
};
|
||||
|
||||
export function useTestPythonTransformOnPyodide(
|
||||
source: PythonTransformSourceDraft,
|
||||
) {
|
||||
const { fetchLibraries, isRunning: isFetchingLibraries } =
|
||||
usePythonLibraries();
|
||||
const { fetchSampleData, isRunning: isFetchingData } = useSampleData(source);
|
||||
const {
|
||||
isRunning: isRunningPython,
|
||||
cancel,
|
||||
data: executionResult,
|
||||
executePython,
|
||||
} = useRunPython<PythonTransformResultData>();
|
||||
|
||||
const run = useCallback(async () => {
|
||||
const [sampleData, libraries] = await Promise.all([
|
||||
fetchSampleData(),
|
||||
fetchLibraries(),
|
||||
]);
|
||||
|
||||
const script = getPythonScript(source.body, sampleData);
|
||||
executePython(script, libraries);
|
||||
}, [source, executePython, fetchSampleData, fetchLibraries]);
|
||||
|
||||
return {
|
||||
isRunning: isRunningPython || isFetchingData || isFetchingLibraries,
|
||||
cancel,
|
||||
run,
|
||||
executionResult,
|
||||
};
|
||||
}
|
||||
|
||||
function getPythonScript(code: string, data: SampleData[]) {
|
||||
// add a random suffix to the main function to avoid it
|
||||
// from being used in the transform function which would lead to
|
||||
// unexpected results.
|
||||
const random = Math.random().toString(36).slice(2);
|
||||
|
||||
// Encode the columns as base64 JSON to avoid issues with escaping and formatting
|
||||
const encoded = btoa(JSON.stringify(data));
|
||||
|
||||
return [
|
||||
// code should sit at the top of the script, so line numbers in errors
|
||||
// are correct
|
||||
code,
|
||||
`
|
||||
def __run_transform_${random}():
|
||||
import json
|
||||
import base64
|
||||
|
||||
if 'transform' not in globals():
|
||||
raise Exception('No transform function defined')
|
||||
|
||||
encoded = '${encoded}'
|
||||
columns = json.loads(
|
||||
base64.b64decode(encoded)
|
||||
)
|
||||
|
||||
kwargs = {}
|
||||
for column in columns:
|
||||
kwargs[column['alias']] = pd.DataFrame(column['rows'])
|
||||
|
||||
# run user-defined transform
|
||||
result = transform(**kwargs)
|
||||
|
||||
if result is None:
|
||||
raise Exception('Transform function did not return a result')
|
||||
|
||||
if not isinstance(result, pd.DataFrame):
|
||||
raise Exception('Transform function did not return a DataFrame')
|
||||
|
||||
cols = [{'name': name} for name in result.columns.tolist()]
|
||||
rows = result.to_dict('records')
|
||||
|
||||
return json.dumps({
|
||||
'cols': cols,
|
||||
'rows': rows,
|
||||
})
|
||||
|
||||
__run_transform_${random}()
|
||||
`,
|
||||
].join("\n");
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export class PyodideWorkerManager {
|
||||
async executePython() {
|
||||
return {
|
||||
output: [],
|
||||
error: undefined,
|
||||
logs: {
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./pyodide-worker-pool";
|
||||
export type { PythonLibraries, PythonExecutionResult } from "./types";
|
||||
@@ -0,0 +1,127 @@
|
||||
import { t } from "ttag";
|
||||
|
||||
import { getErrorMessage } from "metabase/api/utils";
|
||||
|
||||
import type {
|
||||
ExecutePythonOptions,
|
||||
PyodideWorkerCommand,
|
||||
PyodideWorkerMessage,
|
||||
PythonExecutionResult,
|
||||
PythonLibraries,
|
||||
} from "./types";
|
||||
import { withTimeout } from "./utils";
|
||||
|
||||
const READY_TIMEOUT = 60_000;
|
||||
const EXECUTE_TIMEOUT = 60_000;
|
||||
|
||||
export class PyodideWorkerManager {
|
||||
private worker: Worker;
|
||||
private ready: Promise<void>;
|
||||
status: "initializing" | "ready" | "error";
|
||||
|
||||
constructor() {
|
||||
this.status = "initializing";
|
||||
this.worker = new Worker(
|
||||
// This needs to be a URL literal defined inline in the new Worker argument
|
||||
// for rspack to correctly resolve it.
|
||||
new URL(
|
||||
"./pyodide-worker.ts",
|
||||
// @ts-expect-error: TypeScript complains about import.meta.url
|
||||
import.meta.url,
|
||||
),
|
||||
{ name: "pyodide-worker" },
|
||||
);
|
||||
|
||||
this.send({ type: "init" });
|
||||
|
||||
this.ready = this.waitFor("ready", { timeout: READY_TIMEOUT })
|
||||
.then(() => {
|
||||
this.status = "ready";
|
||||
})
|
||||
.catch((err) => {
|
||||
this.status = "error";
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
async executePython<T>(
|
||||
code: string,
|
||||
libraries: PythonLibraries = {},
|
||||
options?: ExecutePythonOptions,
|
||||
): Promise<PythonExecutionResult<T>> {
|
||||
options?.signal?.addEventListener("abort", () => {
|
||||
// Terminate the worker, which will cause waitFor to reject with "Worker terminated"
|
||||
this.terminate();
|
||||
});
|
||||
|
||||
try {
|
||||
await this.ready;
|
||||
|
||||
this.send({ type: "execute", code, libraries });
|
||||
|
||||
const evt = await this.waitFor("results", {
|
||||
timeout: EXECUTE_TIMEOUT,
|
||||
signal: options?.signal,
|
||||
});
|
||||
|
||||
return {
|
||||
output: evt.result ? JSON.parse(evt.result) : null,
|
||||
error: evt.error,
|
||||
logs: evt.logs,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
error: { message: getErrorMessage(error) },
|
||||
};
|
||||
} finally {
|
||||
this.terminate();
|
||||
}
|
||||
}
|
||||
|
||||
private send(message: PyodideWorkerCommand) {
|
||||
this.worker.postMessage(message);
|
||||
}
|
||||
|
||||
private terminate() {
|
||||
this.send({ type: "terminate" });
|
||||
}
|
||||
|
||||
private waitFor<T extends PyodideWorkerMessage["type"]>(
|
||||
type: T,
|
||||
{ timeout, signal }: { timeout: number; signal?: AbortSignal },
|
||||
): Promise<Extract<PyodideWorkerMessage, { type: T }>> {
|
||||
return new Promise((resolve, reject) => {
|
||||
withTimeout(signal, timeout).addEventListener("abort", (reason) => {
|
||||
unsubscribe();
|
||||
reject(reason ?? new Error(t`Aborted`));
|
||||
});
|
||||
|
||||
const handleMessage = ({ data }: MessageEvent<PyodideWorkerMessage>) => {
|
||||
switch (data.type) {
|
||||
case type:
|
||||
unsubscribe();
|
||||
return resolve(data as Extract<PyodideWorkerMessage, { type: T }>);
|
||||
case "error":
|
||||
unsubscribe();
|
||||
return reject(data.error);
|
||||
case "terminated":
|
||||
unsubscribe();
|
||||
return reject(new Error(t`Worker terminated`));
|
||||
}
|
||||
};
|
||||
|
||||
const handleError = (evt: ErrorEvent) => {
|
||||
unsubscribe();
|
||||
reject(evt.error ?? new Error(t`Could not start Python worker.`));
|
||||
};
|
||||
|
||||
const unsubscribe = () => {
|
||||
this.worker.removeEventListener("message", handleMessage);
|
||||
this.worker.removeEventListener("error", handleError);
|
||||
};
|
||||
|
||||
this.worker.addEventListener("message", handleMessage);
|
||||
this.worker.addEventListener("error", handleError);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { PyodideWorkerManager } from "./pyodide-worker-manager";
|
||||
import type {
|
||||
ExecutePythonOptions,
|
||||
PythonExecutionResult,
|
||||
PythonLibraries,
|
||||
} from "./types";
|
||||
|
||||
const MAX_PYODIDE_WORKERS = 5;
|
||||
|
||||
// A pool of Pyodide workers.
|
||||
// The pool attemps to keep MAX_PYODIDE_WORKERS workers ready at all times.
|
||||
// Pyodide workers cannot be reused because the user-script can polute
|
||||
// the global namespace in the Python vm.
|
||||
//
|
||||
// Starting up a worker is a little bit slow, and running the actual script is relatively fast.
|
||||
// Therefore, we start up MAX_PYODIDE_WORKERS at once so they are ready to use when needed.
|
||||
export class PyodideWorkerPool {
|
||||
workers: PyodideWorkerManager[];
|
||||
|
||||
constructor() {
|
||||
this.workers = Array.from(
|
||||
{ length: MAX_PYODIDE_WORKERS },
|
||||
() => new PyodideWorkerManager(),
|
||||
);
|
||||
}
|
||||
|
||||
async executePython<T>(
|
||||
code: string,
|
||||
libraries: PythonLibraries = {},
|
||||
options?: ExecutePythonOptions,
|
||||
): Promise<PythonExecutionResult<T>> {
|
||||
const worker = this.getWorker();
|
||||
return worker.executePython(code, libraries, options);
|
||||
}
|
||||
|
||||
private getWorker(): PyodideWorkerManager {
|
||||
// remove all workers that have errored out
|
||||
this.workers = this.workers.filter((worker) => worker.status !== "error");
|
||||
|
||||
// add a new worker to the pool so there is always at least one in the pool
|
||||
this.workers.push(new PyodideWorkerManager());
|
||||
|
||||
// pick a worker that is ready if possible
|
||||
const idx = this.workers.findIndex((worker) => worker.status === "ready");
|
||||
const jdx = idx === -1 ? 0 : idx;
|
||||
return this.workers.splice(jdx, 1)[0] ?? new PyodideWorkerManager();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
// The pyodide package is not made for use on the web, so we
|
||||
// cannot use it directly. Instead we import it for it's type definitions.
|
||||
// eslint-disable-next-line import/namespace
|
||||
import type * as Pyodide from "pyodide";
|
||||
|
||||
import type {
|
||||
PyodideWorkerCommand,
|
||||
PyodideWorkerMessage,
|
||||
PythonLibraries,
|
||||
} from "./types";
|
||||
|
||||
const PACKAGES = ["pandas", "numpy"];
|
||||
|
||||
let pyodide: Pyodide.PyodideAPI | null = null;
|
||||
|
||||
self.addEventListener("unhandledrejection", (event) => {
|
||||
event.stopPropagation();
|
||||
send({ type: "error", error: event.reason });
|
||||
terminate();
|
||||
});
|
||||
|
||||
self.addEventListener(
|
||||
"message",
|
||||
({ data }: MessageEvent<PyodideWorkerCommand>) => {
|
||||
switch (data.type) {
|
||||
case "init":
|
||||
return init();
|
||||
case "terminate":
|
||||
return terminate();
|
||||
case "execute":
|
||||
return execute(data);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Send a message to the main thread.
|
||||
*/
|
||||
function send(message: PyodideWorkerMessage) {
|
||||
self.postMessage(message);
|
||||
}
|
||||
|
||||
function terminate() {
|
||||
send({ type: "terminated" });
|
||||
self.close();
|
||||
}
|
||||
|
||||
async function init() {
|
||||
// Import pyodide from local assets
|
||||
self.importScripts("/app/assets/pyodide/pyodide.js");
|
||||
|
||||
// @ts-expect-error: loadPyodide is put in the global scope by pyodide.js
|
||||
const loader = loadPyodide as typeof Pyodide.loadPyodide;
|
||||
|
||||
pyodide = await loader({
|
||||
indexURL: "/app/assets/pyodide/",
|
||||
packages: PACKAGES,
|
||||
});
|
||||
|
||||
// Import the packages on initialization since that is slow
|
||||
await pyodide.runPythonAsync(
|
||||
PACKAGES.map((pkg) => `import ${pkg}`).join("\n"),
|
||||
);
|
||||
|
||||
// Add helper function that grabs the last exception
|
||||
// and formats it as a string
|
||||
await pyodide.runPythonAsync(
|
||||
`
|
||||
def __format_exception():
|
||||
import sys
|
||||
from traceback import format_exception
|
||||
return "".join(
|
||||
format_exception(sys.last_type, sys.last_value, sys.last_traceback)
|
||||
)
|
||||
`,
|
||||
);
|
||||
|
||||
// Disallow importing certain modules to prevent security issues
|
||||
await pyodide.runPythonAsync(`
|
||||
import sys
|
||||
import builtins
|
||||
|
||||
def __override():
|
||||
# Banned modules for security - comprehensive list to prevent sandbox escapes
|
||||
|
||||
# JavaScript bridge and Pyodide internals:
|
||||
# - "js": Prevent access to JavaScript bridge to avoid DOM manipulation and XSS
|
||||
# - "pyodide": Prevent Pyodide API manipulation to avoid sandbox escape
|
||||
# - "_pyodide_core": Prevent low-level Pyodide internals access
|
||||
# - "micropip": CRITICAL - prevents dynamic package installation that bypasses restrictions
|
||||
|
||||
# Network access modules (prevent data exfiltration and SSRF):
|
||||
# - "http": HTTP client modules
|
||||
# - "urllib": Standard library HTTP/URL handling (works in Pyodide via Fetch API)
|
||||
# - "urllib3": Third-party HTTP library (supported in Pyodide 2.2.0+)
|
||||
# - "requests": Popular HTTP library (can work with pyodide-http)
|
||||
# - "socket": Low-level network interface
|
||||
# - "ssl": SSL/TLS wrapper for socket objects
|
||||
# - "pyodide.http": Pyodide-specific HTTP module using browser APIs
|
||||
# - "aiohttp": Async HTTP client/server library
|
||||
# - "fsspec": Filesystem abstraction that can access remote storage
|
||||
|
||||
# File system and process access:
|
||||
# - "pathlib": File system path operations
|
||||
# - "os": Environment and process manipulation
|
||||
# - "subprocess": Process execution
|
||||
# - "io": File I/O operations (open files for reading/writing)
|
||||
# - "tempfile": Create temporary files
|
||||
# - "shutil": High-level file operations
|
||||
|
||||
# Import system manipulation (prevent bypassing restrictions):
|
||||
# - "importlib": Dynamic import system that can bypass string-based checks
|
||||
# - "imp": Deprecated import internals (removed in Python 3.12 but may exist in older Pyodide)
|
||||
|
||||
# Code execution and introspection (prevent dynamic code evaluation):
|
||||
# - "code": Interactive interpreter and code execution
|
||||
# - "codeop": Utilities to compile code
|
||||
# - "inspect": Runtime introspection to access internals
|
||||
# - "types": Dynamic type and function creation
|
||||
# - "gc": Garbage collector access to find hidden objects
|
||||
# - "ctypes": Foreign function interface to call C code
|
||||
# - "ast": Abstract syntax tree manipulation
|
||||
|
||||
banned_modules = [
|
||||
# JavaScript and Pyodide internals
|
||||
"js",
|
||||
"pyodide",
|
||||
"_pyodide_core",
|
||||
"micropip",
|
||||
# Network access
|
||||
"http",
|
||||
"urllib",
|
||||
"urllib.request",
|
||||
"urllib.error",
|
||||
"urllib.parse",
|
||||
"urllib.robotparser",
|
||||
"urllib3",
|
||||
"requests",
|
||||
"socket",
|
||||
"ssl",
|
||||
"pyodide.http",
|
||||
"aiohttp",
|
||||
"fsspec",
|
||||
# File system and processes
|
||||
"pathlib",
|
||||
"os",
|
||||
"subprocess",
|
||||
"io",
|
||||
"tempfile",
|
||||
"shutil",
|
||||
# Import system
|
||||
"importlib",
|
||||
"imp",
|
||||
# Code execution and introspection
|
||||
"code",
|
||||
"codeop",
|
||||
"inspect",
|
||||
"types",
|
||||
"gc",
|
||||
"ctypes",
|
||||
"ast",
|
||||
]
|
||||
|
||||
# Banned built-in functions that allow code execution or system access
|
||||
dangerous_builtins = [
|
||||
"open",
|
||||
]
|
||||
|
||||
real_import = builtins.__import__
|
||||
|
||||
def restricted_import(name, globals=None, locals=None, fromlist=(), level=0):
|
||||
# Check both the module name and any parent modules
|
||||
module_parts = name.split(".")
|
||||
for i in range(len(module_parts)):
|
||||
partial_name = ".".join(module_parts[:i+1])
|
||||
if partial_name in banned_modules:
|
||||
raise ImportError(f"Access to the {partial_name} module is disabled for security reasons.")
|
||||
return real_import(name, globals, locals, fromlist, level)
|
||||
|
||||
# Override the default import builtin to block importing of banned modules
|
||||
builtins.__import__ = restricted_import
|
||||
|
||||
# Remove dangerous built-in functions
|
||||
for func_name in dangerous_builtins:
|
||||
if func_name in dir(builtins):
|
||||
# Replace with a function that raises an error
|
||||
def make_restricted_func(name):
|
||||
def restricted_func(*args, **kwargs):
|
||||
raise RuntimeError(f"The {name} function is disabled for security reasons.")
|
||||
return restricted_func
|
||||
setattr(builtins, func_name, make_restricted_func(func_name))
|
||||
|
||||
# Remove the imported modules from sys.modules if they were imported during
|
||||
# initialization to ensure they cannot be accessed even if previously loaded
|
||||
for name in list(sys.modules.keys()):
|
||||
for banned in banned_modules:
|
||||
if name == banned or name.startswith(banned + "."):
|
||||
sys.modules.pop(name, None)
|
||||
break
|
||||
|
||||
__override()
|
||||
`);
|
||||
|
||||
// Signal that worker is ready to accept messages
|
||||
send({ type: "ready" });
|
||||
}
|
||||
|
||||
async function execute({
|
||||
code,
|
||||
libraries,
|
||||
}: {
|
||||
code: string;
|
||||
libraries: PythonLibraries;
|
||||
}) {
|
||||
const result = await executePython(code, libraries);
|
||||
send({ type: "results", ...result });
|
||||
}
|
||||
|
||||
async function executePython(code: string, libraries: PythonLibraries) {
|
||||
if (!pyodide) {
|
||||
throw new Error("Pyodide not initialized");
|
||||
}
|
||||
|
||||
const logs: string[] = [];
|
||||
pyodide.setStdout({
|
||||
batched(out: string) {
|
||||
logs.push(out);
|
||||
},
|
||||
});
|
||||
|
||||
pyodide.setStderr({
|
||||
batched(out: string) {
|
||||
logs.push(out);
|
||||
},
|
||||
});
|
||||
|
||||
for (const [name, library] of Object.entries(libraries)) {
|
||||
pyodide.FS.writeFile(name, library);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await pyodide.runPythonAsync(code);
|
||||
return {
|
||||
result: serialize(result),
|
||||
logs: logs.join("\n"),
|
||||
};
|
||||
} catch (pythonExecutionError) {
|
||||
console.error("Python execution error:", pythonExecutionError);
|
||||
|
||||
const formatException = pyodide.globals.get(
|
||||
"__format_exception",
|
||||
) as () => string;
|
||||
const error = formatException();
|
||||
return {
|
||||
error: { message: serialize(error) },
|
||||
logs: logs.join("\n"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Deep serialization function to handle nested complex objects
|
||||
function serialize(obj: unknown) {
|
||||
if (
|
||||
typeof obj === "object" &&
|
||||
obj !== null &&
|
||||
"toJs" in obj &&
|
||||
typeof obj.toJs === "function"
|
||||
) {
|
||||
return JSON.parse(JSON.stringify(obj.toJs({ create_pyproxies: false })));
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
export type PythonLibraries = Record<string, string>;
|
||||
|
||||
export type PyodideWorkerCommand =
|
||||
| PyodideInitCommand
|
||||
| PyodideExecuteCommand
|
||||
| PyodideTerminateCommand;
|
||||
|
||||
type PyodideInitCommand = { type: "init" };
|
||||
|
||||
type PyodideExecuteCommand = {
|
||||
type: "execute";
|
||||
code: string;
|
||||
libraries: PythonLibraries;
|
||||
};
|
||||
|
||||
type PyodideTerminateCommand = { type: "terminate" };
|
||||
|
||||
type PyodideErrorMessage = { type: "error"; error: Error };
|
||||
|
||||
type PyodideReadyMessage = { type: "ready" };
|
||||
|
||||
type PyodideTerminatedMessage = { type: "terminated" };
|
||||
|
||||
export type PyodideResultsMessage = {
|
||||
type: "results";
|
||||
error?: { message: string };
|
||||
result?: string;
|
||||
logs?: string;
|
||||
};
|
||||
|
||||
export type PyodideWorkerMessage =
|
||||
| PyodideReadyMessage
|
||||
| PyodideResultsMessage
|
||||
| PyodideErrorMessage
|
||||
| PyodideTerminatedMessage;
|
||||
|
||||
export type ExecutePythonOptions = {
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
export type PythonExecutionResult<T = unknown> = {
|
||||
output?: T;
|
||||
error?: { message: string };
|
||||
logs?: string;
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
export function withTimeout(signal: AbortSignal | undefined, ms: number) {
|
||||
const controller = new AbortController();
|
||||
function abort(reason?: any) {
|
||||
clearTimeout(timeout);
|
||||
controller.abort(reason);
|
||||
}
|
||||
signal?.addEventListener("abort", abort);
|
||||
const timeout = setTimeout(() => abort(new Error("Timed out")), ms);
|
||||
return controller.signal;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { DatabaseId } from "./database";
|
||||
import type { RowValue } from "./dataset";
|
||||
import type { PaginationRequest, PaginationResponse } from "./pagination";
|
||||
import type { DatasetQuery } from "./query";
|
||||
import type { ScheduleDisplayType } from "./settings";
|
||||
@@ -185,18 +186,18 @@ export type ListTransformRunsResponse = {
|
||||
data: TransformRun[];
|
||||
} & PaginationResponse;
|
||||
|
||||
export type ExecutePythonTransformRequest = {
|
||||
export type TestPythonTransformRequest = {
|
||||
code: string;
|
||||
tables: PythonTransformTableAliases;
|
||||
source_tables: PythonTransformTableAliases;
|
||||
};
|
||||
|
||||
export type ExecutePythonTransformResponse = {
|
||||
output?: string;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
error?: string;
|
||||
exit_code?: number;
|
||||
timeout?: boolean;
|
||||
export type TestPythonTransformResponse = {
|
||||
logs?: string;
|
||||
error?: { message: string };
|
||||
output?: {
|
||||
cols: { name: string }[];
|
||||
rows: Record<string, RowValue>[];
|
||||
};
|
||||
};
|
||||
|
||||
export type PythonLibrary = {
|
||||
|
||||
@@ -95,6 +95,7 @@ export const datasetApi = Api.injectEndpoints({
|
||||
|
||||
export const {
|
||||
useGetAdhocQueryQuery,
|
||||
useLazyGetAdhocQueryQuery,
|
||||
useGetAdhocPivotQueryQuery,
|
||||
useGetAdhocQueryMetadataQuery,
|
||||
useLazyGetAdhocQueryMetadataQuery,
|
||||
|
||||
@@ -52,3 +52,7 @@ jest.mock("@uiw/react-codemirror", () => {
|
||||
default: MockEditor,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock(
|
||||
"metabase-enterprise/transforms-python/services/pyodide-worker/pyodide-worker-manager",
|
||||
);
|
||||
|
||||
@@ -107,6 +107,7 @@
|
||||
"password-generator": "^2.3.2",
|
||||
"process": "^0.11.10",
|
||||
"prop-types": "^15.5.7",
|
||||
"pyodide": "^0.28.3",
|
||||
"re-reselect": "^4.0.1",
|
||||
"react": "^18.2.0",
|
||||
"react-ansi-style": "^1.0.0",
|
||||
@@ -437,6 +438,7 @@
|
||||
"concurrently": "yarn && concurrently --kill-others -p name",
|
||||
"dev": "yarn && yarn clean-dev:cljs && concurrently -n 'backend,frontend,cljs,static-viz' -c 'blue,green,yellow,magenta,red' 'clojure -M:run:dev:dev-start --hot' 'yarn build-hot:js-wait' 'yarn build:cljs' 'yarn build-static-viz:watch-wait'",
|
||||
"dev-hot-be": "yarn && yarn clean-dev:cljs && concurrently -n 'backend,frontend,cljs,static-viz' -c 'blue,green,yellow,magenta,red' 'clojure -M:run:dev:dev-start --hot' 'ENABLE_CLJS_HOT_RELOAD=true yarn build-hot:js-wait' 'yarn build-hot:cljs' 'yarn build-static-viz:watch-wait'",
|
||||
"download-pyodide": "./bin/download-pyodide.sh",
|
||||
"dev-ee": "yarn && yarn clean-dev:cljs && concurrently -n 'backend,frontend,cljs,static-viz' -c 'blue,green,yellow,magenta,red' 'clojure -M:run:ee:dev:dev-start --hot' 'MB_EDITION=ee yarn build-hot:js-wait' 'MB_EDITION=ee yarn build:cljs' 'yarn build-static-viz:watch-wait'",
|
||||
"dev-ee-hot-be": "yarn && yarn clean-dev:cljs && concurrently -n 'backend,frontend,cljs,static-viz' -c 'blue,green,yellow,magenta,red' 'clojure -M:run:ee:dev:dev-start --hot' 'MB_EDITION=ee ENABLE_CLJS_HOT_RELOAD=true yarn build-hot:js-wait' 'MB_EDITION=ee yarn build-hot:cljs' 'yarn build-static-viz:watch-wait'",
|
||||
"embedding-sdk:generate-package": "node ./bin/embedding-sdk/generate-sdk-package-files.js",
|
||||
@@ -466,7 +468,7 @@
|
||||
"lint-prettier": "yarn && yarn lint-prettier-pure",
|
||||
"lint-prettier-pure": "prettier --check '{frontend,enterprise/frontend,e2e}/**/*.{js,jsx,ts,tsx,css}'",
|
||||
"lint-yaml": "yamllint **/*.{yaml,yml} --ignore=node_modules/**/*.{yaml,yml}",
|
||||
"postinstall": "if [ -z \"$CI\" ]; then yarn-deduplicate --scopes @babel && yarn patch-package; fi",
|
||||
"postinstall": "if [ -z \"$CI\" ]; then yarn-deduplicate --scopes @babel && yarn patch-package; fi && yarn run download-pyodide",
|
||||
"precommit": "lint-staged --shell",
|
||||
"preinstall": "echo $npm_execpath | grep -q yarn || echo '\\033[0;33mSorry, npm is not supported. Please use Yarn (https://yarnpkg.com/).\\033[0m'",
|
||||
"prepare": "husky install",
|
||||
|
||||
@@ -43,7 +43,8 @@ const TEST_SUPPORT_PATH = __dirname + "/frontend/test/__support__";
|
||||
const BUILD_PATH = __dirname + "/resources/frontend_client";
|
||||
const E2E_PATH = __dirname + "/e2e";
|
||||
|
||||
const PORT = process.env.MB_FRONTEND_DEV_PORT || 8080;
|
||||
const FRONTEND_DEV_PORT = process.env.MB_FRONTEND_DEV_PORT || 8080;
|
||||
const SERVER_PORT = process.env.MB_JETTY_PORT || 3000;
|
||||
const isDevMode = IS_DEV_MODE;
|
||||
const shouldEnableHotRefresh = WEBPACK_BUNDLE === "hot";
|
||||
|
||||
@@ -342,10 +343,10 @@ if (shouldEnableHotRefresh) {
|
||||
|
||||
// point the publicPath (inlined in index.html by HtmlWebpackPlugin) to the hot-reloading server
|
||||
config.output.publicPath =
|
||||
`http://localhost:${PORT}/` + config.output.publicPath;
|
||||
`http://localhost:${SERVER_PORT}/` + config.output.publicPath;
|
||||
|
||||
config.devServer = {
|
||||
port: PORT, // make the port explicit so it errors if it's already in use
|
||||
port: FRONTEND_DEV_PORT, // make the port explicit so it errors if it's already in use
|
||||
hot: true,
|
||||
client: {
|
||||
progress: false,
|
||||
|
||||
@@ -179,7 +179,8 @@
|
||||
(when config/is-dev?
|
||||
"ws://*:9630")]
|
||||
:manifest-src ["'self'"]
|
||||
:media-src ["www.metabase.com"]}]
|
||||
:media-src ["www.metabase.com"]
|
||||
:worker-src ["'self'"]}] ;; Allow WebWorkers from same origin for Pyodide execution
|
||||
(format "%s %s; " (name k) (str/join " " vs))))})
|
||||
|
||||
(defn- content-security-policy-header-with-frame-ancestors
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
"Main Compojure routes tables. See https://github.com/weavejester/compojure/wiki/Routes-In-Detail for details about
|
||||
how these work. `/api/` routes are in [[metabase.api-routes.routes]]."
|
||||
(:require
|
||||
[compojure.core :as compojure :refer #_{:clj-kondo/ignore [:discouraged-var]} [context defroutes GET OPTIONS]]
|
||||
[clj-http.client :as http]
|
||||
[compojure.core :as compojure :refer #_{:clj-kondo/ignore [:discouraged-var]} [context defroutes GET OPTIONS ANY]]
|
||||
[compojure.route :as route]
|
||||
[environ.core :as env]
|
||||
[metabase.api.macros :as api.macros]
|
||||
[metabase.app-db.core :as mdb]
|
||||
[metabase.appearance.core :as appearance]
|
||||
[metabase.config.core :as config]
|
||||
[metabase.core.initialization-status :as init-status]
|
||||
[metabase.query-processor.schema :as qp.schema]
|
||||
[metabase.server.auth-wrapper :as auth-wrapper]
|
||||
@@ -60,11 +63,37 @@
|
||||
([_request respond _raise]
|
||||
(respond (health-handler))))
|
||||
|
||||
(def ^:private frontend-dev-port
|
||||
(or (env/env :mb-frontend-dev-port) "8080"))
|
||||
|
||||
(defn ^:private proxy-dev-server
|
||||
"Proxy the rspack dev server, so all assets are served from the same origin."
|
||||
[request]
|
||||
(let [proxied-url (str "http://localhost:"
|
||||
frontend-dev-port
|
||||
(:uri request)
|
||||
(when-some [qs (:query-string request)]
|
||||
(str "?" qs)))
|
||||
response (http/request {:method (:request-method request)
|
||||
:url proxied-url
|
||||
:headers (dissoc (:headers request) "host")
|
||||
:body (:body request)
|
||||
:as :stream
|
||||
:throw-exceptions false})]
|
||||
{:status (:status response)
|
||||
:headers (:headers response)
|
||||
:body (:body response)}))
|
||||
|
||||
#_{:clj-kondo/ignore [:discouraged-var]}
|
||||
(defroutes ^:private static-files-handler
|
||||
(GET "/embedding-sdk.js" request
|
||||
((mw.embedding-sdk-bundle/serve-bundle-handler) request))
|
||||
|
||||
;; proxy the rspack dev server
|
||||
(if config/is-dev?
|
||||
(ANY "/dist/*" req (proxy-dev-server req))
|
||||
(route/resources "/dist/" {:root "frontend_client/app/dist"}))
|
||||
|
||||
;; fall back to serving _all_ other files under /app
|
||||
(route/resources "/" {:root "frontend_client/app"})
|
||||
(route/not-found {:status 404 :body "Not found."}))
|
||||
|
||||
@@ -17848,6 +17848,13 @@ pvutils@^1.1.3:
|
||||
resolved "https://registry.yarnpkg.com/pvutils/-/pvutils-1.1.3.tgz#f35fc1d27e7cd3dfbd39c0826d173e806a03f5a3"
|
||||
integrity sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==
|
||||
|
||||
pyodide@^0.28.3:
|
||||
version "0.28.3"
|
||||
resolved "https://registry.yarnpkg.com/pyodide/-/pyodide-0.28.3.tgz#5a97241448898c89778fd404731c2b9877064dbc"
|
||||
integrity sha512-rtCsyTU55oNGpLzSVuAd55ZvruJDEX8o6keSdWKN9jPeBVSNlynaKFG7eRqkiIgU7i2M6HEgYtm0atCEQX3u4A==
|
||||
dependencies:
|
||||
ws "^8.5.0"
|
||||
|
||||
qified@^0.5.0:
|
||||
version "0.5.0"
|
||||
resolved "https://registry.yarnpkg.com/qified/-/qified-0.5.0.tgz#2e0a1f8bdf2321764b976402da9fbfc1a205785f"
|
||||
@@ -22339,7 +22346,7 @@ ws@^7, ws@^7.2.0, ws@^7.3.1, ws@^7.4.6, ws@^7.5.5:
|
||||
resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.10.tgz#58b5c20dc281633f6c19113f39b349bd8bd558d9"
|
||||
integrity sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==
|
||||
|
||||
ws@^8.13.0:
|
||||
ws@^8.13.0, ws@^8.5.0:
|
||||
version "8.18.3"
|
||||
resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.3.tgz#b56b88abffde62791c639170400c93dcb0c95472"
|
||||
integrity sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==
|
||||
|
||||
Reference in New Issue
Block a user