Compare commits

...

115 Commits

Author SHA1 Message Date
Uladzimir Havenchyk
39a68def7e cleanup sandboxing 2025-10-30 22:30:51 +03:00
Uladzimir Havenchyk
3da641ba00 fix typecheck 2025-10-29 16:15:09 +03:00
Uladzimir Havenchyk
91247c3be2 Merge remote-tracking branch 'origin/nmezzopera/test-pyodie' into test-python-be 2025-10-29 16:03:16 +03:00
Uladzimir Havenchyk
ed4f399a01 add a comment 2025-10-28 18:49:55 +03:00
Uladzimir Havenchyk
a291c967c7 improve sandboxing 2025-10-28 18:49:12 +03:00
Uladzimir Havenchyk
4f1dec3ce8 add a comment 2025-10-28 16:42:41 +03:00
Uladzimir Havenchyk
f6347c776d log python error 2025-10-28 16:42:25 +03:00
Uladzimir Havenchyk
ae3c2c7a2a move mocks to proper place 2025-10-28 16:40:03 +03:00
Uladzimir Havenchyk
3a985e46a2 lint 2025-10-28 16:13:18 +03:00
Uladzimir Havenchyk
ba2ae00dac fix typecheck 2025-10-28 14:28:01 +03:00
Uladzimir Havenchyk
0df6adcc51 remove duplicated button 2025-10-28 14:20:52 +03:00
Uladzimir Havenchyk
87c2392827 Merge remote-tracking branch 'origin/master' into nmezzopera/test-pyodie 2025-10-28 13:32:00 +03:00
Uladzimir Havenchyk
eda355803b Merge remote-tracking branch 'origin/master' into nmezzopera/test-pyodie 2025-10-26 16:39:15 +03:00
Uladzimir Havenchyk
e6df0dc8c5 Merge remote-tracking branch 'origin/master' into nmezzopera/test-pyodie 2025-10-26 16:02:00 +03:00
Uladzimir Havenchyk
7fac1f9d24 Merge remote-tracking branch 'origin/master' into nmezzopera/test-pyodie 2025-10-23 00:06:52 +03:00
Romeo Van Snick
4bc3b6484b Ban certain imports in Python script 2025-10-13 17:02:06 +11:00
Romeo Van Snick
ee29d769a0 Add more tests for backend test runner 2025-10-10 14:25:02 +11:00
Romeo Van Snick
013974b705 Improve python runner mode switch 2025-10-10 13:53:51 +11:00
Romeo Van Snick
202290121b Remove unused helper 2025-10-09 16:08:02 +11:00
Romeo Van Snick
5766d22bce Fix flaky test 2025-10-09 15:59:46 +11:00
Romeo Van Snick
7b500427ec Increase timeout for python runner 2025-10-09 15:47:00 +11:00
Romeo Van Snick
d0a36c3d7c Fix misplaced docstring 2025-10-09 14:56:32 +11:00
Romeo Van Snick
ef7ee34349 Add UI to switch between browser runner and api runnner 2025-10-09 14:55:27 +11:00
Romeo Van Snick
24d6fc3d7d WIP 2025-10-09 14:37:27 +11:00
Romeo Van Snick
e00705930f Update python transform endpoint 2025-10-09 14:34:44 +11:00
Romeo Van Snick
a85b69ce0d Adjust pyodide worker types to align with test endpoint 2025-10-09 14:33:20 +11:00
Romeo Van Snick
dff77917f4 Remove blob: for worker-src 2025-10-09 13:17:38 +11:00
Romeo Van Snick
c25d90382f Fix metababot broken code 2025-10-09 13:16:46 +11:00
Romeo Van Snick
42d8c14a8e Improve comments 2025-10-09 13:14:45 +11:00
Romeo Van Snick
6b37f5f3af Add more documentation on the worker pool 2025-10-09 13:13:54 +11:00
Romeo Van Snick
809503986a Only unsubscribe when message is handled 2025-10-09 13:10:13 +11:00
Romeo Van Snick
b551d305fc Remove any 2025-10-09 13:08:59 +11:00
Romeo Van Snick
b24f87f5d1 Remove todo 2025-10-09 13:08:38 +11:00
Romeo Van Snick
9c95e9b8a7 Add comment to proxy route 2025-10-09 13:06:59 +11:00
Romeo Van Snick
8e8480fc95 Only add querystring when it exists
Co-authored-by: metabase-bot[bot] <109303359+metabase-bot[bot]@users.noreply.github.com>
2025-10-09 13:04:29 +11:00
Romeo Van Snick
abf5f8caa8 Top-level comments should have three semicolons
Co-authored-by: metabase-bot[bot] <109303359+metabase-bot[bot]@users.noreply.github.com>
2025-10-09 13:00:52 +11:00
Romeo Van Snick
d13f7355c5 Decompress archive 2025-10-09 12:49:19 +11:00
Romeo Van Snick
e560d95105 Fix typo 2025-10-09 12:48:36 +11:00
Romeo Van Snick
e7828cc2f8 Remove redundant declare 2025-10-09 12:48:09 +11:00
Romeo Van Snick
0af03be7cc Merge branch 'master' into nmezzopera/test-pyodie
# Conflicts:
#	enterprise/frontend/src/metabase-enterprise/transforms-python/components/PythonTransformEditor/PythonEditorResults/PythonEditorResults.module.css
2025-10-09 12:31:14 +11:00
Romeo Van Snick
26915a1feb Remove log 2025-10-09 12:30:03 +11:00
Romeo Van Snick
8fcd5d3839 Do not store pyodide packages in git 2025-10-09 12:28:29 +11:00
Romeo Van Snick
39cefbab7f Revert "Remove pyodide from github repo"
This reverts commit 95bb56b5e8.
2025-10-08 23:11:00 +11:00
Romeo Van Snick
e002ff8980 Revert "Ignore code issues in vendored library"
This reverts commit 3606567fc8.
2025-10-08 23:10:04 +11:00
Romeo Van Snick
95bb56b5e8 Remove pyodide from github repo 2025-10-08 22:50:35 +11:00
Romeo Van Snick
1e03d56dac Install pyodide on install 2025-10-08 22:49:38 +11:00
Romeo Van Snick
a5d21e9623 Add script to download pyodide's distro 2025-10-08 22:49:27 +11:00
Romeo Van Snick
3606567fc8 Ignore code issues in vendored library 2025-10-08 21:12:10 +11:00
Romeo Van Snick
a3b380ca00 Remove template string quotes 2025-10-08 20:57:55 +11:00
Romeo Van Snick
59ec9091d8 Increase ready timout 2025-10-08 20:57:36 +11:00
Romeo Van Snick
158ce159f7 Add mock for PyodideWorkerManager 2025-10-08 15:52:25 +11:00
Romeo Van Snick
a6a737b33b Handle app/dist when not in dev mode 2025-10-08 13:09:57 +11:00
Romeo Van Snick
af75705b3c Remove unused argument 2025-10-08 13:05:41 +11:00
Romeo Van Snick
f904f950ba Rename variable to be clearer 2025-10-08 11:12:49 +11:00
Romeo Van Snick
64a8ec3593 Add search params to request 2025-10-08 10:47:43 +11:00
Romeo Van Snick
39e2e551dc Simplify worker loops 2025-10-07 17:02:26 +11:00
Romeo Van Snick
7606803604 Add termination better 2025-10-07 16:30:53 +11:00
Romeo Van Snick
7fd1c79dad Ignore results for aborted runs 2025-10-07 16:04:04 +11:00
Romeo Van Snick
1a7017612c Use types from pyodide 2025-10-07 15:41:05 +11:00
Romeo Van Snick
4420d0c963 Add pyodide 2025-10-07 15:28:16 +11:00
Romeo Van Snick
3a00a330bc Handle errored out workers 2025-10-07 15:21:56 +11:00
Romeo Van Snick
22211b18e8 Fix types 2025-10-07 15:10:14 +11:00
Romeo Van Snick
bc5bcff995 Improve waitFor error handling 2025-10-07 15:05:51 +11:00
Romeo Van Snick
27456e9669 Split up code further 2025-10-07 14:42:30 +11:00
Romeo Van Snick
13854b5c49 Work of shared types for Worker and Manager 2025-10-07 14:35:36 +11:00
Romeo Van Snick
143ed4974c Simplify packages 2025-10-07 14:20:37 +11:00
Romeo Van Snick
bca7756059 Move pyodide worker into a separate folder 2025-10-07 14:18:49 +11:00
Romeo Van Snick
186aa9e644 Add types to Pyodide worker 2025-10-07 10:19:48 +11:00
Romeo Van Snick
1c92dfb462 Proxy webpack dev server 2025-10-07 10:13:18 +11:00
Romeo Van Snick
9cae2a5080 Use worker in webpack 2025-10-07 10:13:18 +11:00
Romeo Van Snick
127b23d986 Add e2e test for Python runner 2025-10-06 19:49:00 +11:00
Romeo Van Snick
62917d13bc Handle python shared libraries 2025-10-03 18:12:49 +10:00
Romeo Van Snick
47720ac7dc Allow adding custom libraries to Pyodide 2025-10-03 17:59:51 +10:00
Romeo Van Snick
003c9e63b3 Remove Pyodide status indicator 2025-10-03 17:57:56 +10:00
Romeo Van Snick
5c9d18b96a Render error traceback in a more readable way 2025-10-03 17:48:42 +10:00
Romeo Van Snick
054c0477c0 Remove DataFetcher 2025-10-03 17:43:08 +10:00
Romeo Van Snick
c8da77672c Remove any 2025-10-03 17:41:41 +10:00
Romeo Van Snick
7360cd88b6 Remove pyodide dependency 2025-10-03 17:38:53 +10:00
Romeo Van Snick
55c789fa26 Remove downlaod script for pyodide 2025-10-03 17:34:36 +10:00
Romeo Van Snick
a93959a4c5 Pass args as kwargs and wrap them in a dataframe 2025-10-03 17:33:52 +10:00
Romeo Van Snick
0e1bfe79f2 Add helper to fetch sample data and use that instead of the service 2025-10-03 17:26:27 +10:00
Romeo Van Snick
1efe3a5675 Move transform related wrapper out of python worker pool 2025-10-03 17:04:41 +10:00
Romeo Van Snick
6ecbad3323 Move Python transform test to hooks 2025-10-03 16:58:17 +10:00
Romeo Van Snick
8ece3416e3 Handle abortion properly 2025-10-03 16:54:35 +10:00
Romeo Van Snick
c360c2f457 Add useRunPython hook and use that in the test python hook 2025-10-03 16:51:45 +10:00
Romeo Van Snick
63b43a98d5 Improve python script by wrapping columns in JSON 2025-10-03 16:11:10 +10:00
Romeo Van Snick
d4f385694f Handle newlines in stdout/stderr 2025-10-03 16:02:40 +10:00
Romeo Van Snick
a3d7c7e9f3 Handle errors in script 2025-10-03 15:53:30 +10:00
Romeo Van Snick
96ab264d7a Pick worker that is ready in pool 2025-10-03 13:38:49 +10:00
Romeo Van Snick
59ebd75cec Remove unused imports 2025-10-03 13:28:04 +10:00
Romeo Van Snick
1ff9264e4c Disable undefined variable lint 2025-10-03 09:58:57 +10:00
Romeo Van Snick
692acf2531 Rename manager to pool 2025-10-03 09:56:39 +10:00
Romeo Van Snick
e0d12b32e4 Make packages configurable for the worker 2025-10-03 09:55:21 +10:00
Romeo Van Snick
b47e6f4313 Import packages when initializing 2025-10-03 09:47:17 +10:00
Romeo Van Snick
bdd9dccdc9 Do not include micropip 2025-10-02 22:55:10 +10:00
Romeo Van Snick
35d5f824f9 Remove unused context 2025-10-02 22:54:44 +10:00
Romeo Van Snick
c99b613fb3 Add cancellation to pyodide worker 2025-10-02 22:54:44 +10:00
Romeo Van Snick
5fc1f7309f Distinguish worker and workermanager 2025-10-02 22:54:44 +10:00
Romeo Van Snick
b02eb58a79 Remove initialize step altogether 2025-10-02 22:31:51 +10:00
Romeo Van Snick
337f5bb867 Automatically terminate worker after run 2025-10-02 22:21:40 +10:00
Romeo Van Snick
958390151e Handle errors in the worker 2025-10-02 22:20:08 +10:00
Romeo Van Snick
09377ed5e9 Remove crazy serialization code 2025-10-02 22:15:48 +10:00
Romeo Van Snick
8334d3d91b Simplify worker flow 2025-10-02 22:06:49 +10:00
Romeo Van Snick
66f7eb7039 Wrap case in block 2025-10-02 18:55:14 +10:00
Romeo Van Snick
d228328a29 Load packages on initialization 2025-10-02 18:47:10 +10:00
Romeo Van Snick
cd8d1a8348 Remove unused loadPackages handler 2025-10-02 18:38:24 +10:00
Romeo Van Snick
0c0b1f7a4b Clear globals 2025-10-02 18:36:16 +10:00
Romeo Van Snick
4e168d7e8c Use pyodide builtins to capture stdout/stderr 2025-10-02 18:36:03 +10:00
Romeo Van Snick
8b92275ceb Enable Python debugger by default 2025-10-02 17:09:20 +10:00
Romeo Van Snick
9f04f3ce31 Remove rspack changes 2025-10-02 17:05:17 +10:00
Romeo Van Snick
3ce8af616d Remove unused @pyodide/webpack-plugin 2025-10-02 17:04:47 +10:00
Romeo Van Snick
577aa9ee51 Format worker 2025-10-02 16:52:44 +10:00
Romeo Van Snick
5b9d0ce9fd Format table values 2025-10-02 14:30:18 +10:00
Romeo Van Snick
e800c0673d Improve error / output renderer 2025-10-02 14:18:59 +10:00
Nicolo-Maria
5a7469143b poc: pyodie test mode 2025-10-01 12:31:22 +02:00
32 changed files with 1314 additions and 289 deletions

1
.gitignore vendored
View File

@@ -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
View 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[@]}

View File

@@ -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");
}

View File

@@ -29,5 +29,8 @@ export const pythonLibraryApi = EnterpriseApi.injectEndpoints({
}),
});
export const { useGetPythonLibraryQuery, useUpdatePythonLibraryMutation } =
pythonLibraryApi;
export const {
useGetPythonLibraryQuery,
useLazyGetPythonLibraryQuery,
useUpdatePythonLibraryMutation,
} = pythonLibraryApi;

View File

@@ -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;

View File

@@ -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";

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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>
);
}

View File

@@ -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,
};
}

View File

@@ -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>

View File

@@ -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,
};
}

View File

@@ -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` };

View File

@@ -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,
};
}

View File

@@ -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 };
}

View File

@@ -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,
};
}

View File

@@ -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");
}

View File

@@ -0,0 +1,12 @@
export class PyodideWorkerManager {
async executePython() {
return {
output: [],
error: undefined,
logs: {
stdout: "",
stderr: "",
},
};
}
}

View File

@@ -0,0 +1,2 @@
export * from "./pyodide-worker-pool";
export type { PythonLibraries, PythonExecutionResult } from "./types";

View File

@@ -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);
});
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}

View File

@@ -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;
};

View File

@@ -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;
}

View File

@@ -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 = {

View File

@@ -95,6 +95,7 @@ export const datasetApi = Api.injectEndpoints({
export const {
useGetAdhocQueryQuery,
useLazyGetAdhocQueryQuery,
useGetAdhocPivotQueryQuery,
useGetAdhocQueryMetadataQuery,
useLazyGetAdhocQueryMetadataQuery,

View File

@@ -52,3 +52,7 @@ jest.mock("@uiw/react-codemirror", () => {
default: MockEditor,
};
});
jest.mock(
"metabase-enterprise/transforms-python/services/pyodide-worker/pyodide-worker-manager",
);

View File

@@ -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",

View File

@@ -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,

View File

@@ -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

View File

@@ -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."}))

View File

@@ -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==