Compare commits

...

9 Commits

Author SHA1 Message Date
Romeo Van Snick
fd50ffa7dd Parametrise PythonLibraryEditor 2025-09-12 16:04:06 +10:00
Romeo Van Snick
2880033f06 Update python lib terminology and parametrise API 2025-09-12 15:54:41 +10:00
Romeo Van Snick
87161c6d71 Add shared lib toggle 2025-09-12 12:42:19 +10:00
Romeo Van Snick
89117dbfb0 Remove trailing whitespace 2025-09-12 11:27:36 +10:00
Romeo Van Snick
3a1b93b79d Add python library editor 2025-09-11 20:29:59 +10:00
Romeo Van Snick
2f651e074d Allow passing className to python editor 2025-09-11 20:28:01 +10:00
Romeo Van Snick
54a2642138 Add api endpoints for python library 2025-09-11 20:28:01 +10:00
Romeo Van Snick
d3a74d538d Add fullWidth prop to admin layout 2025-09-11 20:28:01 +10:00
Romeo Van Snick
82b097feb8 Move PythonCodeEditor to separate component 2025-09-11 20:28:01 +10:00
19 changed files with 984 additions and 684 deletions

View File

@@ -0,0 +1,35 @@
import { Api } from "metabase/api";
export type PythonLibrary = {
source: string;
};
export type GetPythonLibraryRequest = {
name: string;
};
export type UpdatePythonLibraryRequest = {
name: string;
source: string;
};
export const pythonLibraryApi = Api.injectEndpoints({
endpoints: (builder) => ({
getPythonLibrary: builder.query<PythonLibrary, GetPythonLibraryRequest>({
query: ({ name }) => ({
url: `/api/ee/python-transform/user-modules/${name}`,
method: "GET",
}),
}),
updatePythonLibrary: builder.mutation<void, UpdatePythonLibraryRequest>({
query: ({ name, source }) => ({
url: `/api/ee/python-transform/user-modules/${name}`,
method: "PUT",
body: { source },
}),
}),
}),
});
export const { useGetPythonLibraryQuery, useUpdatePythonLibraryMutation } =
pythonLibraryApi;

View File

@@ -0,0 +1,4 @@
.editor {
width: 100%;
height: 100%;
}

View File

@@ -0,0 +1,28 @@
import cx from "classnames";
import { CodeEditor } from "metabase/common/components/CodeEditor";
import S from "./PythonEditor.module.css";
import { completion } from "./utils";
export function PythonEditor({
value,
onChange,
withPandasCompletions,
className,
}: {
value: string;
onChange: (value: string) => void;
withPandasCompletions?: boolean;
className?: string;
}) {
return (
<CodeEditor
className={cx(S.editor, className)}
value={value}
onChange={onChange}
language="python"
extensions={withPandasCompletions ? completion : undefined}
/>
);
}

View File

@@ -0,0 +1 @@
export * from "./PythonEditor";

View File

@@ -0,0 +1,675 @@
import {
type Completion,
type CompletionContext,
autocompletion,
} from "@codemirror/autocomplete";
import { pythonLanguage } from "@codemirror/lang-python";
// This is the closest you can get to "loading pandas definitions"
const pandasDefinitions: Record<string, Completion[]> = {
pd: [
{
label: "DataFrame",
type: "class",
info: "Two-dimensional, size-mutable, potentially heterogeneous tabular data",
},
{
label: "Series",
type: "class",
info: "One-dimensional ndarray with axis labels",
},
{
label: "read_csv",
type: "function",
info: "Read a comma-separated values (csv) file into DataFrame",
},
{
label: "read_excel",
type: "function",
info: "Read an Excel file into a pandas DataFrame",
},
{
label: "read_json",
type: "function",
info: "Convert a JSON string to pandas object",
},
{
label: "read_sql",
type: "function",
info: "Read SQL query or database table into a DataFrame",
},
{
label: "read_parquet",
type: "function",
info: "Load a parquet object from file path",
},
{
label: "concat",
type: "function",
info: "Concatenate pandas objects along a particular axis",
},
{
label: "merge",
type: "function",
info: "Merge DataFrame or named Series objects with a database-style join",
},
{
label: "pivot_table",
type: "function",
info: "Create a spreadsheet-style pivot table as a DataFrame",
},
{
label: "crosstab",
type: "function",
info: "Compute a simple cross tabulation of two (or more) factors",
},
{
label: "get_dummies",
type: "function",
info: "Convert categorical variable(s) into dummy/indicator variables",
},
{
label: "date_range",
type: "function",
info: "Return a fixed frequency DatetimeIndex",
},
{
label: "to_datetime",
type: "function",
info: "Convert argument to datetime",
},
{
label: "to_numeric",
type: "function",
info: "Convert argument to a numeric type",
},
{
label: "cut",
type: "function",
info: "Bin values into discrete intervals",
},
{
label: "qcut",
type: "function",
info: "Quantile-based discretization function",
},
{
label: "isna",
type: "function",
info: "Detect missing values for an array-like object",
},
{
label: "isnull",
type: "function",
info: "Detect missing values for an array-like object",
},
{
label: "notna",
type: "function",
info: "Detect non-missing values for an array-like object",
},
{
label: "notnull",
type: "function",
info: "Detect non-missing values for an array-like object",
},
],
// DataFrame methods and properties
DataFrame: [
// Viewing/Inspection
{ label: "head", type: "method", info: "Return the first n rows" },
{ label: "tail", type: "method", info: "Return the last n rows" },
{
label: "info",
type: "method",
info: "Print a concise summary of a DataFrame",
},
{
label: "describe",
type: "method",
info: "Generate descriptive statistics",
},
{
label: "shape",
type: "property",
info: "Return a tuple representing the dimensionality",
},
{
label: "size",
type: "property",
info: "Return an int representing the number of elements",
},
{
label: "columns",
type: "property",
info: "The column labels of the DataFrame",
},
{
label: "index",
type: "property",
info: "The index (row labels) of the DataFrame",
},
{
label: "dtypes",
type: "property",
info: "Return the dtypes in the DataFrame",
},
{
label: "values",
type: "property",
info: "Return a Numpy representation of the DataFrame",
},
// Selection/Indexing
{
label: "loc",
type: "property",
info: "Access a group of rows and columns by label(s)",
},
{
label: "iloc",
type: "property",
info: "Purely integer-location based indexing",
},
{
label: "at",
type: "property",
info: "Access a single value for a row/column label pair",
},
{
label: "iat",
type: "property",
info: "Access a single value for a row/column pair by integer position",
},
{
label: "query",
type: "method",
info: "Query the columns of a DataFrame with a boolean expression",
},
{
label: "filter",
type: "method",
info: "Subset the dataframe rows or columns according to labels",
},
{
label: "where",
type: "method",
info: "Replace values where the condition is False",
},
{
label: "mask",
type: "method",
info: "Replace values where the condition is True",
},
// Data Cleaning
{
label: "drop",
type: "method",
info: "Drop specified labels from rows or columns",
},
{
label: "drop_duplicates",
type: "method",
info: "Return DataFrame with duplicate rows removed",
},
{ label: "dropna", type: "method", info: "Remove missing values" },
{
label: "fillna",
type: "method",
info: "Fill NA/NaN values using the specified method",
},
{
label: "replace",
type: "method",
info: "Replace values given in to_replace with value",
},
{
label: "interpolate",
type: "method",
info: "Fill NaN values using an interpolation method",
},
{
label: "duplicated",
type: "method",
info: "Return boolean Series denoting duplicate rows",
},
{ label: "isnull", type: "method", info: "Detect missing values" },
{ label: "isna", type: "method", info: "Detect missing values" },
{
label: "notnull",
type: "method",
info: "Detect existing (non-missing) values",
},
{
label: "notna",
type: "method",
info: "Detect existing (non-missing) values",
},
// Transformations
{
label: "sort_values",
type: "method",
info: "Sort by the values along either axis",
},
{
label: "sort_index",
type: "method",
info: "Sort object by labels (along an axis)",
},
{
label: "reset_index",
type: "method",
info: "Reset the index, or a level of it",
},
{
label: "set_index",
type: "method",
info: "Set the DataFrame index using existing columns",
},
{
label: "reindex",
type: "method",
info: "Conform Series/DataFrame to new index",
},
{ label: "rename", type: "method", info: "Alter axes labels" },
{
label: "assign",
type: "method",
info: "Assign new columns to a DataFrame",
},
{
label: "pipe",
type: "method",
info: "Apply chainable functions that expect Series or DataFrames",
},
// Aggregation/GroupBy
{
label: "groupby",
type: "method",
info: "Group DataFrame using a mapper or by a Series of columns",
},
{
label: "agg",
type: "method",
info: "Aggregate using one or more operations",
},
{
label: "aggregate",
type: "method",
info: "Aggregate using one or more operations",
},
{
label: "apply",
type: "method",
info: "Apply a function along an axis of the DataFrame",
},
{
label: "applymap",
type: "method",
info: "Apply a function to a Dataframe elementwise",
},
{
label: "transform",
type: "method",
info: "Call func on self producing a DataFrame with transformed values",
},
{
label: "rolling",
type: "method",
info: "Provide rolling window calculations",
},
{
label: "expanding",
type: "method",
info: "Provide expanding transformations",
},
{ label: "resample", type: "method", info: "Resample time-series data" },
// Statistical Methods
{
label: "sum",
type: "method",
info: "Return the sum of the values for the requested axis",
},
{
label: "mean",
type: "method",
info: "Return the mean of the values for the requested axis",
},
{
label: "median",
type: "method",
info: "Return the median of the values for the requested axis",
},
{
label: "mode",
type: "method",
info: "Get the mode(s) of each element along the selected axis",
},
{
label: "std",
type: "method",
info: "Return sample standard deviation over requested axis",
},
{
label: "var",
type: "method",
info: "Return unbiased variance over requested axis",
},
{
label: "min",
type: "method",
info: "Return the minimum of the values for the requested axis",
},
{
label: "max",
type: "method",
info: "Return the maximum of the values for the requested axis",
},
{
label: "count",
type: "method",
info: "Count non-NA cells for each column or row",
},
{
label: "nunique",
type: "method",
info: "Count number of distinct elements in specified axis",
},
{
label: "value_counts",
type: "method",
info: "Return a Series containing counts of unique values",
},
{
label: "corr",
type: "method",
info: "Compute pairwise correlation of columns",
},
{
label: "cov",
type: "method",
info: "Compute pairwise covariance of columns",
},
{
label: "rank",
type: "method",
info: "Compute numerical data ranks (1 through n) along axis",
},
{
label: "quantile",
type: "method",
info: "Return values at the given quantile over requested axis",
},
{
label: "clip",
type: "method",
info: "Trim values at input threshold(s)",
},
// Merging/Joining
{
label: "merge",
type: "method",
info: "Merge DataFrame or named Series objects",
},
{
label: "join",
type: "method",
info: "Join columns of another DataFrame",
},
{
label: "concat",
type: "method",
info: "Concatenate pandas objects along a particular axis",
},
{
label: "append",
type: "method",
info: "Append rows of other to the end of caller",
},
{
label: "update",
type: "method",
info: "Modify in place using non-NA values from another DataFrame",
},
// Reshaping
{
label: "pivot",
type: "method",
info: "Return reshaped DataFrame organized by given index / column values",
},
{
label: "pivot_table",
type: "method",
info: "Create a spreadsheet-style pivot table",
},
{
label: "melt",
type: "method",
info: "Unpivot a DataFrame from wide to long format",
},
{
label: "stack",
type: "method",
info: "Stack the prescribed level(s) from columns to index",
},
{
label: "unstack",
type: "method",
info: "Pivot a level of the (necessarily hierarchical) index labels",
},
{ label: "transpose", type: "method", info: "Transpose index and columns" },
{ label: "T", type: "property", info: "Transpose index and columns" },
// I/O
{
label: "to_csv",
type: "method",
info: "Write object to a comma-separated values (csv) file",
},
{
label: "to_excel",
type: "method",
info: "Write object to an Excel sheet",
},
{
label: "to_json",
type: "method",
info: "Convert the object to a JSON string",
},
{
label: "to_sql",
type: "method",
info: "Write records stored in a DataFrame to a SQL database",
},
{
label: "to_parquet",
type: "method",
info: "Write a DataFrame to the binary parquet format",
},
{
label: "to_dict",
type: "method",
info: "Convert the DataFrame to a dictionary",
},
{
label: "to_html",
type: "method",
info: "Render a DataFrame as an HTML table",
},
{
label: "to_string",
type: "method",
info: "Render a DataFrame to a console-friendly tabular output",
},
{
label: "to_numpy",
type: "method",
info: "Convert the DataFrame to a NumPy array",
},
// Time Series
{
label: "shift",
type: "method",
info: "Shift index by desired number of periods",
},
{ label: "tshift", type: "method", info: "Shift the time index" },
{
label: "diff",
type: "method",
info: "First discrete difference of element",
},
{
label: "pct_change",
type: "method",
info: "Percentage change between the current and a prior element",
},
// Plotting (if matplotlib available)
{
label: "plot",
type: "method",
info: "Make plots of Series or DataFrame",
},
{
label: "hist",
type: "method",
info: "Make a histogram of the DataFrame's columns",
},
{
label: "boxplot",
type: "method",
info: "Make a box plot of the DataFrame columns",
},
],
// Series methods (subset of DataFrame methods that make sense for Series)
Series: [
{ label: "head", type: "method", info: "Return the first n values" },
{ label: "tail", type: "method", info: "Return the last n values" },
{
label: "describe",
type: "method",
info: "Generate descriptive statistics",
},
{
label: "value_counts",
type: "method",
info: "Return a Series containing counts of unique values",
},
{
label: "unique",
type: "method",
info: "Return unique values of Series object",
},
{
label: "nunique",
type: "method",
info: "Return number of unique elements in the object",
},
{ label: "sort_values", type: "method", info: "Sort by the values" },
{
label: "sort_index",
type: "method",
info: "Sort Series by index labels",
},
{
label: "reset_index",
type: "method",
info: "Generate a new DataFrame or Series with the index reset",
},
{
label: "dropna",
type: "method",
info: "Return a new Series with missing values removed",
},
{
label: "fillna",
type: "method",
info: "Fill NA/NaN values using the specified method",
},
{
label: "apply",
type: "method",
info: "Invoke function on values of Series",
},
{
label: "map",
type: "method",
info: "Map values of Series according to input correspondence",
},
{
label: "replace",
type: "method",
info: "Replace values given in to_replace with value",
},
{
label: "str",
type: "property",
info: "Vectorized string functions for Series and Index",
},
{
label: "dt",
type: "property",
info: "Accessor object for datetimelike properties",
},
{
label: "cat",
type: "property",
info: "Accessor object for categorical properties",
},
],
};
function genericPandasCompletions(context: CompletionContext) {
const word = context.matchBefore(/[\w.]*/);
if (!word || (word.from === word.to && !context.explicit)) {
return null;
}
const text = word.text;
// Match patterns like "pd.", "df.", etc.
if (text.includes(".")) {
const parts = text.split(".");
const obj = parts.at(-2);
const partial = parts.at(-1) ?? "";
if (!obj || !pandasDefinitions[obj]) {
return null;
}
const options = pandasDefinitions[obj]
.filter(
(item) =>
!partial ||
item.label.toLowerCase().startsWith(partial.toLowerCase()),
)
.map((comp) => ({
...comp,
info: undefined,
boost: comp.type === "method" ? 2 : comp.type === "function" ? 1 : 0,
}));
return {
filter: false,
from: word.from + text.length - partial.length,
options,
};
}
}
export const completion = [
pythonLanguage.data.of({
autocomplete: genericPandasCompletions,
}),
autocompletion(),
];

View File

@@ -60,3 +60,9 @@
right: 0;
background-color: rgba(255, 255, 255, 0.5);
}
.libraryActions {
position: absolute;
top: 0;
right: 0;
}

View File

@@ -3,18 +3,27 @@ import { ResizableBox } from "react-resizable";
import { c, t } from "ttag";
import EmptyCodeResult from "assets/img/empty-states/code.svg";
import { CodeEditor } from "metabase/common/components/CodeEditor";
import DebouncedFrame from "metabase/common/components/DebouncedFrame";
import Link from "metabase/common/components/Link";
import { LoadingSpinner } from "metabase/common/components/MetadataInfo/MetadataInfo.styled";
import { isMac } from "metabase/lib/browser";
import RunButtonWithTooltip from "metabase/query_builder/components/RunButtonWithTooltip";
import { Box, Flex, Icon, Stack, Text, Title } from "metabase/ui";
import { Box, Checkbox, Flex, Icon, Stack, Text, Title } from "metabase/ui";
import { getPythonLibraryUrl } from "metabase-enterprise/transforms/urls";
import { SHARED_LIB_IMPORT_NAME } from "../../../constants";
import { PythonEditor as PythonCodeEditor } from "../../PythonEditor";
import { ResizableBoxHandle } from "../EditorBody/ResizableBoxHandle";
import { ExecutionOutputTable } from "./ExecutionOutputTable";
import S from "./PythonEditor.module.css";
import { type ExecutionResult, completion, useTestPythonScript } from "./utils";
import {
type ExecutionResult,
hasImport,
insertImport,
removeImport,
useTestPythonScript,
} from "./utils";
type PythonEditorProps = {
script: string;
@@ -36,6 +45,15 @@ export function PythonEditor({
const { isRunning, isDirty, cancel, run, executionResult } =
useTestPythonScript(script, tables);
const hasSharedLib = hasImport(script, SHARED_LIB_IMPORT_NAME);
function handleToggleSharedLib() {
if (hasImport(script, SHARED_LIB_IMPORT_NAME)) {
onChange(removeImport(script, SHARED_LIB_IMPORT_NAME));
} else {
onChange(insertImport(script, SHARED_LIB_IMPORT_NAME));
}
}
return (
<Stack h="100%" w="100%" gap={0}>
<ResizableBox
@@ -46,12 +64,10 @@ export function PythonEditor({
resizeHandles={["s"]}
>
<Flex h="100%" align="end" bg="bg-light">
<CodeEditor
className={S.editor}
<PythonCodeEditor
value={script}
onChange={onChange}
language="python"
extensions={completion}
withPandasCompletions
/>
<Box p="md">
@@ -65,6 +81,23 @@ export function PythonEditor({
/>
</Box>
</Flex>
<Stack className={S.libraryActions} p="md" gap="sm">
<Checkbox
label={t`Import shared library`}
checked={hasSharedLib}
onChange={handleToggleSharedLib}
size="sm"
/>
<Flex
component={Link}
target="_blank"
to={getPythonLibraryUrl({ name: SHARED_LIB_IMPORT_NAME })}
gap="sm"
>
<Icon name="pencil" />
{t`Edit shared library`}
</Flex>
</Stack>
</ResizableBox>
<DebouncedFrame className={S.visualization}>

View File

@@ -1,9 +1,3 @@
import {
type Completion,
type CompletionContext,
autocompletion,
} from "@codemirror/autocomplete";
import { pythonLanguage } from "@codemirror/lang-python";
import { useRef, useState } from "react";
import { t } from "ttag";
@@ -106,672 +100,32 @@ export function useTestPythonScript(
};
}
// Static Pandas Definitions for CodeMirror Autocomplete
// This is the closest you can get to "loading pandas definitions"
export function insertImport(source: string, lib: string) {
const lines = source.split("\n");
const pandasDefinitions: Record<string, Completion[]> = {
pd: [
{
label: "DataFrame",
type: "class",
info: "Two-dimensional, size-mutable, potentially heterogeneous tabular data",
},
{
label: "Series",
type: "class",
info: "One-dimensional ndarray with axis labels",
},
{
label: "read_csv",
type: "function",
info: "Read a comma-separated values (csv) file into DataFrame",
},
{
label: "read_excel",
type: "function",
info: "Read an Excel file into a pandas DataFrame",
},
{
label: "read_json",
type: "function",
info: "Convert a JSON string to pandas object",
},
{
label: "read_sql",
type: "function",
info: "Read SQL query or database table into a DataFrame",
},
{
label: "read_parquet",
type: "function",
info: "Load a parquet object from file path",
},
{
label: "concat",
type: "function",
info: "Concatenate pandas objects along a particular axis",
},
{
label: "merge",
type: "function",
info: "Merge DataFrame or named Series objects with a database-style join",
},
{
label: "pivot_table",
type: "function",
info: "Create a spreadsheet-style pivot table as a DataFrame",
},
{
label: "crosstab",
type: "function",
info: "Compute a simple cross tabulation of two (or more) factors",
},
{
label: "get_dummies",
type: "function",
info: "Convert categorical variable(s) into dummy/indicator variables",
},
{
label: "date_range",
type: "function",
info: "Return a fixed frequency DatetimeIndex",
},
{
label: "to_datetime",
type: "function",
info: "Convert argument to datetime",
},
{
label: "to_numeric",
type: "function",
info: "Convert argument to a numeric type",
},
{
label: "cut",
type: "function",
info: "Bin values into discrete intervals",
},
{
label: "qcut",
type: "function",
info: "Quantile-based discretization function",
},
{
label: "isna",
type: "function",
info: "Detect missing values for an array-like object",
},
{
label: "isnull",
type: "function",
info: "Detect missing values for an array-like object",
},
{
label: "notna",
type: "function",
info: "Detect non-missing values for an array-like object",
},
{
label: "notnull",
type: "function",
info: "Detect non-missing values for an array-like object",
},
],
// DataFrame methods and properties
DataFrame: [
// Viewing/Inspection
{ label: "head", type: "method", info: "Return the first n rows" },
{ label: "tail", type: "method", info: "Return the last n rows" },
{
label: "info",
type: "method",
info: "Print a concise summary of a DataFrame",
},
{
label: "describe",
type: "method",
info: "Generate descriptive statistics",
},
{
label: "shape",
type: "property",
info: "Return a tuple representing the dimensionality",
},
{
label: "size",
type: "property",
info: "Return an int representing the number of elements",
},
{
label: "columns",
type: "property",
info: "The column labels of the DataFrame",
},
{
label: "index",
type: "property",
info: "The index (row labels) of the DataFrame",
},
{
label: "dtypes",
type: "property",
info: "Return the dtypes in the DataFrame",
},
{
label: "values",
type: "property",
info: "Return a Numpy representation of the DataFrame",
},
// Selection/Indexing
{
label: "loc",
type: "property",
info: "Access a group of rows and columns by label(s)",
},
{
label: "iloc",
type: "property",
info: "Purely integer-location based indexing",
},
{
label: "at",
type: "property",
info: "Access a single value for a row/column label pair",
},
{
label: "iat",
type: "property",
info: "Access a single value for a row/column pair by integer position",
},
{
label: "query",
type: "method",
info: "Query the columns of a DataFrame with a boolean expression",
},
{
label: "filter",
type: "method",
info: "Subset the dataframe rows or columns according to labels",
},
{
label: "where",
type: "method",
info: "Replace values where the condition is False",
},
{
label: "mask",
type: "method",
info: "Replace values where the condition is True",
},
// Data Cleaning
{
label: "drop",
type: "method",
info: "Drop specified labels from rows or columns",
},
{
label: "drop_duplicates",
type: "method",
info: "Return DataFrame with duplicate rows removed",
},
{ label: "dropna", type: "method", info: "Remove missing values" },
{
label: "fillna",
type: "method",
info: "Fill NA/NaN values using the specified method",
},
{
label: "replace",
type: "method",
info: "Replace values given in to_replace with value",
},
{
label: "interpolate",
type: "method",
info: "Fill NaN values using an interpolation method",
},
{
label: "duplicated",
type: "method",
info: "Return boolean Series denoting duplicate rows",
},
{ label: "isnull", type: "method", info: "Detect missing values" },
{ label: "isna", type: "method", info: "Detect missing values" },
{
label: "notnull",
type: "method",
info: "Detect existing (non-missing) values",
},
{
label: "notna",
type: "method",
info: "Detect existing (non-missing) values",
},
// Transformations
{
label: "sort_values",
type: "method",
info: "Sort by the values along either axis",
},
{
label: "sort_index",
type: "method",
info: "Sort object by labels (along an axis)",
},
{
label: "reset_index",
type: "method",
info: "Reset the index, or a level of it",
},
{
label: "set_index",
type: "method",
info: "Set the DataFrame index using existing columns",
},
{
label: "reindex",
type: "method",
info: "Conform Series/DataFrame to new index",
},
{ label: "rename", type: "method", info: "Alter axes labels" },
{
label: "assign",
type: "method",
info: "Assign new columns to a DataFrame",
},
{
label: "pipe",
type: "method",
info: "Apply chainable functions that expect Series or DataFrames",
},
// Aggregation/GroupBy
{
label: "groupby",
type: "method",
info: "Group DataFrame using a mapper or by a Series of columns",
},
{
label: "agg",
type: "method",
info: "Aggregate using one or more operations",
},
{
label: "aggregate",
type: "method",
info: "Aggregate using one or more operations",
},
{
label: "apply",
type: "method",
info: "Apply a function along an axis of the DataFrame",
},
{
label: "applymap",
type: "method",
info: "Apply a function to a Dataframe elementwise",
},
{
label: "transform",
type: "method",
info: "Call func on self producing a DataFrame with transformed values",
},
{
label: "rolling",
type: "method",
info: "Provide rolling window calculations",
},
{
label: "expanding",
type: "method",
info: "Provide expanding transformations",
},
{ label: "resample", type: "method", info: "Resample time-series data" },
// Statistical Methods
{
label: "sum",
type: "method",
info: "Return the sum of the values for the requested axis",
},
{
label: "mean",
type: "method",
info: "Return the mean of the values for the requested axis",
},
{
label: "median",
type: "method",
info: "Return the median of the values for the requested axis",
},
{
label: "mode",
type: "method",
info: "Get the mode(s) of each element along the selected axis",
},
{
label: "std",
type: "method",
info: "Return sample standard deviation over requested axis",
},
{
label: "var",
type: "method",
info: "Return unbiased variance over requested axis",
},
{
label: "min",
type: "method",
info: "Return the minimum of the values for the requested axis",
},
{
label: "max",
type: "method",
info: "Return the maximum of the values for the requested axis",
},
{
label: "count",
type: "method",
info: "Count non-NA cells for each column or row",
},
{
label: "nunique",
type: "method",
info: "Count number of distinct elements in specified axis",
},
{
label: "value_counts",
type: "method",
info: "Return a Series containing counts of unique values",
},
{
label: "corr",
type: "method",
info: "Compute pairwise correlation of columns",
},
{
label: "cov",
type: "method",
info: "Compute pairwise covariance of columns",
},
{
label: "rank",
type: "method",
info: "Compute numerical data ranks (1 through n) along axis",
},
{
label: "quantile",
type: "method",
info: "Return values at the given quantile over requested axis",
},
{
label: "clip",
type: "method",
info: "Trim values at input threshold(s)",
},
// Merging/Joining
{
label: "merge",
type: "method",
info: "Merge DataFrame or named Series objects",
},
{
label: "join",
type: "method",
info: "Join columns of another DataFrame",
},
{
label: "concat",
type: "method",
info: "Concatenate pandas objects along a particular axis",
},
{
label: "append",
type: "method",
info: "Append rows of other to the end of caller",
},
{
label: "update",
type: "method",
info: "Modify in place using non-NA values from another DataFrame",
},
// Reshaping
{
label: "pivot",
type: "method",
info: "Return reshaped DataFrame organized by given index / column values",
},
{
label: "pivot_table",
type: "method",
info: "Create a spreadsheet-style pivot table",
},
{
label: "melt",
type: "method",
info: "Unpivot a DataFrame from wide to long format",
},
{
label: "stack",
type: "method",
info: "Stack the prescribed level(s) from columns to index",
},
{
label: "unstack",
type: "method",
info: "Pivot a level of the (necessarily hierarchical) index labels",
},
{ label: "transpose", type: "method", info: "Transpose index and columns" },
{ label: "T", type: "property", info: "Transpose index and columns" },
// I/O
{
label: "to_csv",
type: "method",
info: "Write object to a comma-separated values (csv) file",
},
{
label: "to_excel",
type: "method",
info: "Write object to an Excel sheet",
},
{
label: "to_json",
type: "method",
info: "Convert the object to a JSON string",
},
{
label: "to_sql",
type: "method",
info: "Write records stored in a DataFrame to a SQL database",
},
{
label: "to_parquet",
type: "method",
info: "Write a DataFrame to the binary parquet format",
},
{
label: "to_dict",
type: "method",
info: "Convert the DataFrame to a dictionary",
},
{
label: "to_html",
type: "method",
info: "Render a DataFrame as an HTML table",
},
{
label: "to_string",
type: "method",
info: "Render a DataFrame to a console-friendly tabular output",
},
{
label: "to_numpy",
type: "method",
info: "Convert the DataFrame to a NumPy array",
},
// Time Series
{
label: "shift",
type: "method",
info: "Shift index by desired number of periods",
},
{ label: "tshift", type: "method", info: "Shift the time index" },
{
label: "diff",
type: "method",
info: "First discrete difference of element",
},
{
label: "pct_change",
type: "method",
info: "Percentage change between the current and a prior element",
},
// Plotting (if matplotlib available)
{
label: "plot",
type: "method",
info: "Make plots of Series or DataFrame",
},
{
label: "hist",
type: "method",
info: "Make a histogram of the DataFrame's columns",
},
{
label: "boxplot",
type: "method",
info: "Make a box plot of the DataFrame columns",
},
],
// Series methods (subset of DataFrame methods that make sense for Series)
Series: [
{ label: "head", type: "method", info: "Return the first n values" },
{ label: "tail", type: "method", info: "Return the last n values" },
{
label: "describe",
type: "method",
info: "Generate descriptive statistics",
},
{
label: "value_counts",
type: "method",
info: "Return a Series containing counts of unique values",
},
{
label: "unique",
type: "method",
info: "Return unique values of Series object",
},
{
label: "nunique",
type: "method",
info: "Return number of unique elements in the object",
},
{ label: "sort_values", type: "method", info: "Sort by the values" },
{
label: "sort_index",
type: "method",
info: "Sort Series by index labels",
},
{
label: "reset_index",
type: "method",
info: "Generate a new DataFrame or Series with the index reset",
},
{
label: "dropna",
type: "method",
info: "Return a new Series with missing values removed",
},
{
label: "fillna",
type: "method",
info: "Fill NA/NaN values using the specified method",
},
{
label: "apply",
type: "method",
info: "Invoke function on values of Series",
},
{
label: "map",
type: "method",
info: "Map values of Series according to input correspondence",
},
{
label: "replace",
type: "method",
info: "Replace values given in to_replace with value",
},
{
label: "str",
type: "property",
info: "Vectorized string functions for Series and Index",
},
{
label: "dt",
type: "property",
info: "Accessor object for datetimelike properties",
},
{
label: "cat",
type: "property",
info: "Accessor object for categorical properties",
},
],
};
function genericPandasCompletions(context: CompletionContext) {
const word = context.matchBefore(/[\w.]*/);
if (!word || (word.from === word.to && !context.explicit)) {
return null;
}
const text = word.text;
// Match patterns like "pd.", "df.", etc.
if (text.includes(".")) {
const parts = text.split(".");
const obj = parts.at(-2);
const partial = parts.at(-1) ?? "";
if (!obj || !pandasDefinitions[obj]) {
return null;
// Find the first line that is not a comment
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.trim().startsWith("#")) {
continue;
}
const options = pandasDefinitions[obj]
.filter(
(item) =>
!partial ||
item.label.toLowerCase().startsWith(partial.toLowerCase()),
)
.map((comp) => ({
...comp,
info: undefined,
boost: comp.type === "method" ? 2 : comp.type === "function" ? 1 : 0,
}));
return {
filter: false,
from: word.from + text.length - partial.length,
options,
};
lines.splice(i, 0, `import ${lib}`);
break;
}
return lines.join("\n");
}
export const completion = [
pythonLanguage.data.of({
autocomplete: genericPandasCompletions,
}),
autocompletion(),
];
function libImportRegex(lib: string) {
return new RegExp(`import ${lib}`, "g");
}
export function removeImport(source: string, lib: string) {
const lines = source.split("\n");
return lines.filter((line) => !libImportRegex(lib).test(line)).join("\n");
}
export function hasImport(source: string, lib: string) {
const regex = libImportRegex(lib);
return regex.test(source);
}

View File

@@ -6,3 +6,5 @@ export const POLLING_INTERVAL = isCypressActive ? 100 : 3000;
export const FILTER_WIDGET_MIN_WIDTH = 300;
export const FILTER_WIDGET_MAX_HEIGHT = 400;
export const SHARED_LIB_IMPORT_NAME = "lib";

View File

@@ -11,6 +11,7 @@ import { JobPage } from "./pages/JobPage";
import { NewJobPage } from "./pages/NewJobPage";
import { NewPythonTransformPage } from "./pages/NewPythonTransformPage";
import { NewTransformQueryPage } from "./pages/NewTransformQueryPage";
import { PythonLibraryEditorPage } from "./pages/PythonLibraryEditorPage";
import { RunListPage } from "./pages/RunListPage";
import { TransformListPage } from "./pages/TransformListPage";
import { TransformPage } from "./pages/TransformPage";
@@ -33,6 +34,11 @@ if (hasPremiumFeature("transforms")) {
<Route path="runs" component={RunListPage} />
<Route path=":transformId" component={TransformPage} />
</Route>
<Route
component={(props) => <TransformPageLayout {...props} fullWidth />}
>
<Route path="library/:name" component={PythonLibraryEditorPage} />
</Route>
<Route
path="new/:type"
component={(props: any) =>

View File

@@ -20,7 +20,7 @@ import pandas as pd
def transform():
"""
Your transformation function.
Select tables above to add them as function parameters.
Returns:

View File

@@ -0,0 +1,5 @@
.editor {
/* HACK: 133px is the height of the headers */
height: calc(100vh - 133px);
flex-grow: 1;
}

View File

@@ -0,0 +1,119 @@
import { useEffect, useState } from "react";
import { t } from "ttag";
import Button from "metabase/common/components/Button";
import EditBar from "metabase/common/components/EditBar";
import { LoadingAndErrorWrapper } from "metabase/common/components/LoadingAndErrorWrapper";
import { useMetadataToasts } from "metabase/metadata/hooks";
import { Box, Flex } from "metabase/ui";
import {
useGetPythonLibraryQuery,
useUpdatePythonLibraryMutation,
} from "metabase-enterprise/api/python-transform-library";
import { PythonEditor } from "metabase-enterprise/transforms/components/PythonEditor";
import type { PythonLibraryEditorPageParams } from "metabase-enterprise/transforms/types";
import S from "./PythonLibraryEditorPage.module.css";
type PythonLibraryEditorPageProps = {
params: PythonLibraryEditorPageParams;
};
export function PythonLibraryEditorPage({
params,
}: PythonLibraryEditorPageProps) {
const { name } = params;
const [source, setSource] = useState("");
const {
data: library,
isLoading,
error,
} = useGetPythonLibraryQuery({ name });
const [updatePythonLibrary, { isLoading: isSaving }] =
useUpdatePythonLibraryMutation();
const { sendSuccessToast, sendErrorToast } = useMetadataToasts();
useEffect(() => {
setSource(library?.source || "");
}, [library?.source]);
const isDirty = source !== library?.source;
function handleRevert() {
setSource(library?.source || "");
}
async function handleSave() {
try {
await updatePythonLibrary({ name, source }).unwrap();
sendSuccessToast(t`Python library saved`);
} catch (error) {
sendErrorToast(t`Python library could not be saved`);
}
}
if (isLoading || error) {
return (
<Box p="md">
<LoadingAndErrorWrapper loading={isLoading} error={error} />
</Box>
);
}
return (
<Flex h="100%" w="100%" bg="bg-light" gap={0} direction="column">
<LibraryEditorHeader
onSave={handleSave}
onRevert={handleRevert}
isDirty={isDirty}
isSaving={isSaving}
/>
<PythonEditor
value={source}
onChange={setSource}
withPandasCompletions
className={S.editor}
/>
</Flex>
);
}
export function LibraryEditorHeader({
isDirty,
isSaving,
onSave,
onRevert,
}: {
isDirty?: boolean;
isSaving?: boolean;
onSave: () => void;
onRevert: () => void;
}) {
return (
<EditBar
title={t`You are editing the shared Python library`}
admin
buttons={[
<Button
key="save"
onClick={onRevert}
primary
small
disabled={!isDirty || isSaving}
>
{t`Revert`}
</Button>,
<Button
key="save"
onClick={onSave}
primary
small
disabled={!isDirty || isSaving}
>
{t`Save`}
</Button>,
]}
/>
);
}

View File

@@ -0,0 +1 @@
export * from "./PythonLibraryEditorPage";

View File

@@ -8,8 +8,14 @@ import {
import { AdminSettingsLayout } from "metabase/common/components/AdminLayout/AdminSettingsLayout";
import { useSelector } from "metabase/lib/redux";
import { getLocation } from "metabase/selectors/routing";
import { SHARED_LIB_IMPORT_NAME } from "metabase-enterprise/transforms/constants";
import { getJobListUrl, getRunListUrl, getTransformListUrl } from "../../urls";
import {
getJobListUrl,
getPythonLibraryUrl,
getRunListUrl,
getTransformListUrl,
} from "../../urls";
type TransformPageLayoutParams = {
transformId?: string;
@@ -18,17 +24,20 @@ type TransformPageLayoutParams = {
type TransformPageLayoutProps = {
params: TransformPageLayoutParams;
fullWidth?: boolean;
children?: ReactNode;
};
export function TransformPageLayout({
params,
children,
fullWidth,
}: TransformPageLayoutProps) {
return (
<AdminSettingsLayout
sidebar={<TransformSidebar params={params} />}
maw="60rem"
fullWidth={fullWidth}
>
{children}
</AdminSettingsLayout>
@@ -61,6 +70,11 @@ function TransformSidebar({ params }: TransformSidebarProps) {
active={pathname?.startsWith(jobListUrl)}
/>
<AdminNavItem label={t`Runs`} path={getRunListUrl()} icon="list" />
<AdminNavItem
label={t`Python library`}
path={getPythonLibraryUrl({ name: SHARED_LIB_IMPORT_NAME })}
icon="code_block"
/>
</AdminNavWrapper>
);
}

View File

@@ -20,3 +20,7 @@ export type JobListParams = {
nextRunStartTime?: string;
transformTagIds?: TransformTagId[];
};
export type PythonLibraryEditorPageParams = {
name: string;
};

View File

@@ -7,7 +7,11 @@ import type {
TransformJobId,
} from "metabase-types/api";
import type { JobListParams, RunListParams } from "./types";
import type {
JobListParams,
PythonLibraryEditorPageParams,
RunListParams,
} from "./types";
export const ROOT_URL = "/admin/transforms";
@@ -123,3 +127,7 @@ export function getTableMetadataUrl(
) {
return `/admin/datamodel/database/${databaseId}/schema/${databaseId}:${encodeURIComponent(schema ?? "")}/table/${tableId}`;
}
export function getPythonLibraryUrl({ name }: PythonLibraryEditorPageParams) {
return `/admin/transforms/library/${name}`;
}

View File

@@ -22,7 +22,6 @@
display: flex;
justify-content: center;
width: 100%;
padding: 2rem;
position: relative;
background-color: var(--mb-color-bg-light);
}

View File

@@ -10,11 +10,13 @@ import S from "./AdminSettingsLayout.module.css";
export const AdminSettingsLayout = ({
sidebar,
children,
fullWidth = false,
maw = "50rem",
}: {
sidebar?: React.ReactNode;
children?: React.ReactNode;
maw?: string;
fullWidth?: boolean;
}) => {
return (
<Box className={S.Wrapper}>
@@ -24,9 +26,13 @@ export const AdminSettingsLayout = ({
{sidebar}
</Box>
)}
<Box className={S.Content} data-testid="admin-layout-content">
<Box maw={maw} w="100%">
<Box pb="2rem">
<Box
className={S.Content}
data-testid="admin-layout-content"
p={fullWidth ? 0 : "2rem"}
>
<Box maw={fullWidth ? undefined : maw} w="100%">
<Box pb={fullWidth ? 0 : "2rem"}>
<ErrorBoundary>{children ?? <NotFound />}</ErrorBoundary>
</Box>
</Box>