diff --git a/docs/guides/working_with_data/dataframes.md b/docs/guides/working_with_data/dataframes.md index 69a2dd6dab5..9e24a59310a 100644 --- a/docs/guides/working_with_data/dataframes.md +++ b/docs/guides/working_with_data/dataframes.md @@ -46,18 +46,42 @@ df import polars as pl df = pl.read_json( - "https://raw.githubusercontent.com/vega/vega-datasets/master/data/cars.json" +"https://raw.githubusercontent.com/vega/vega-datasets/master/data/cars.json" ) df ``` /// +/// tab | live example + +/// marimo-embed + size: large + +```python +@app.cell +def __(): + import pandas as pd + + pd.read_json( + "https://raw.githubusercontent.com/vega/vega-datasets/master/data/cars.json" + ) + return +``` + +/// + +/// + + To opt out of the rich dataframe viewer, use [`mo.plain`][marimo.plain]: /// tab | pandas ```python +import pandas as pd +import marimo as mo + df = pd.read_json( "https://raw.githubusercontent.com/vega/vega-datasets/master/data/cars.json" ) @@ -69,6 +93,9 @@ mo.plain(df) /// tab | polars ```python +import polars as pl +import marimo as mo + df = pl.read_json( "https://raw.githubusercontent.com/vega/vega-datasets/master/data/cars.json" ) @@ -77,6 +104,27 @@ mo.plain(df) /// +/// tab | live example + +/// marimo-embed + size: large + +```python +@app.cell +def __(): + import pandas as pd + + df = pd.read_json( + "https://raw.githubusercontent.com/vega/vega-datasets/master/data/cars.json" + ) + mo.plain(df) + return +``` + +/// + +/// + ## Transforming dataframes ### No-code transformations @@ -94,6 +142,15 @@ notebook. +The transformations you apply will turn into code which is accessible via the "code" tab. + +
+
+ +
Copy the code of the transformation
+
+
+ /// tab | pandas ```python @@ -114,6 +171,7 @@ transformed_df.value /// + /// tab | polars ```python @@ -134,12 +192,33 @@ transformed_df.value /// -
-
- -
Copy the code of the transformation
-
-
+ +/// tab | live example + +/// marimo-embed + size: large + +```python +@app.cell +def __(): + import pandas as pd + + df = pd.DataFrame({"person": ["Alice", "Bob", "Charlie"], "age": [20, 30, 40]}) + transformed_df = mo.ui.dataframe(df) + transformed_df + return + +@app.cell +def __(): + transformed_df.value + + return +``` + +/// + +/// + ### Custom filters @@ -169,6 +248,7 @@ mo.ui.table(filtered_df) /// tab | polars ```python +# Cell 1 import marimo as mo import polars as pl @@ -192,6 +272,37 @@ mo.ui.table(filtered_df) /// +/// tab | live example + + +/// marimo-embed + size: large + +```python +@app.cell +def __(): + import pandas as pd + + df = pd.DataFrame({"person": ["Alice", "Bob", "Charlie"], "age": [20, 30, 40]}) + return + +@app.cell +def __(): + age_filter = mo.ui.slider(start=0, stop=100, value=50, label="Max age") + age_filter + return + +@app.cell +def __(): + filtered_df = df[df["age"] < age_filter.value] + mo.ui.table(filtered_df) + return +``` + +/// + +/// + ## Select dataframe rows {#selecting-dataframes} Display dataframes as interactive, [selectable charts](plotting.md) using @@ -247,6 +358,30 @@ table.value /// + +/// tab | live example + +/// marimo-embed + size: large + +```python +@app.cell +def __(): + import pandas as pd + + df = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]}) + table = mo.ui.table(df, selection="multi") + table + return + +@app.cell +def __(): + table.value + return +``` + +/// + ## Dataframe panels Dataframe outputs in marimo come with several panels to help you visualize, explore, and page through your data interactively. These panels are accessible via toggles at the bottom-left of a dataframe output. If you need further control, after opening a panel you can @@ -296,6 +431,7 @@ altair.data_transformers.enable("vegafusion") The chart builder toggle lets you rapidly develop charts using a GUI, while also generating Python code to insert in your notebook. Refer to the [chart builder guide](plotting.md#chart-builder) for more details. + ## Example notebook For a comprehensive example of using Polars with marimo, check out our [Polars example notebook](https://github.com/marimo-team/marimo/blob/main/examples/third_party/polars/polars_example.py). diff --git a/frontend/src/components/app-config/user-config-form.tsx b/frontend/src/components/app-config/user-config-form.tsx index 8715db8a753..7636924e903 100644 --- a/frontend/src/components/app-config/user-config-form.tsx +++ b/frontend/src/components/app-config/user-config-form.tsx @@ -484,6 +484,49 @@ export const UserConfigForm: React.FC = () => { )} /> + ( +
+ + + + Beta + + basedpyright ( + + docs + + ) + + + { + field.onChange(Boolean(checked)); + }} + /> + + + + + {field.value && !capabilities.basedpyright && ( + + basedpyright is not available in your current + environment. Please install{" "} + basedpyright in your + environment. + + )} +
+ )} + /> ({ spec={spec} width={70} height={30} + renderer="svg" // @ts-expect-error - Our `loader.load` method is broader than VegaLite's typings but is functionally supported. loader={batchedLoader} style={{ minWidth: "unset", maxHeight: "40px" }} diff --git a/frontend/src/components/data-table/row-viewer-panel/row-viewer.tsx b/frontend/src/components/data-table/row-viewer-panel/row-viewer.tsx index 71964439624..e29f50ec63b 100644 --- a/frontend/src/components/data-table/row-viewer-panel/row-viewer.tsx +++ b/frontend/src/components/data-table/row-viewer-panel/row-viewer.tsx @@ -209,7 +209,7 @@ export const RowViewerPanel: React.FC = ({ () => columnValue, () => columnValue, undefined, - "text-left break-all", + "text-left break-word", ); const copyValue = diff --git a/frontend/src/components/editor/Cell.tsx b/frontend/src/components/editor/Cell.tsx index 2769ef11038..029f585cb67 100644 --- a/frontend/src/components/editor/Cell.tsx +++ b/frontend/src/components/editor/Cell.tsx @@ -726,16 +726,16 @@ const EditableCellComponent = ({ {cellOutput === "below" && outputArea} {serialization && ( - +
{isToplevel && ( - + reusable - + )} } > - {(isToplevel && ( - - )) || ( - + {isToplevel ? ( + + + + ) : ( + + + )} - +
)} { // Column 1: cellId2 (0-80), cellId4 (80-160), cellId5 (160-240) mockGetElementById.mockImplementation((id) => { const idToCellId = id.replace("cell-", ""); - if (idToCellId === cellId1) return createMockElement(0, 100); - if (idToCellId === cellId2) return createMockElement(0, 80); - if (idToCellId === cellId3) return createMockElement(100, 100); - if (idToCellId === cellId4) return createMockElement(80, 80); - if (idToCellId === cellId5) return createMockElement(160, 80); + if (idToCellId === cellId1) { + return createMockElement(0, 100); + } + if (idToCellId === cellId2) { + return createMockElement(0, 80); + } + if (idToCellId === cellId3) { + return createMockElement(100, 100); + } + if (idToCellId === cellId4) { + return createMockElement(80, 80); + } + if (idToCellId === cellId5) { + return createMockElement(160, 80); + } return null; }); @@ -1580,8 +1590,9 @@ describe("useCellEditorNavigationProps", () => { describe("keyboard shortcuts", () => { it("should focus cell when Escape is pressed", () => { + const mockEditorView = { current: null }; const { result } = renderWithProvider(() => - useCellEditorNavigationProps(mockCellId), + useCellEditorNavigationProps(mockCellId, mockEditorView), ); const mockEvent = Mocks.keyboardEvent({ key: "Escape" }); @@ -1594,9 +1605,70 @@ describe("useCellEditorNavigationProps", () => { expect(mockEvent.continuePropagation).not.toHaveBeenCalled(); }); + it("should clear text selection when Escape is pressed with selection", () => { + const mockDispatch = vi.fn(); + const mockEditorView = { + current: { + state: { + selection: { main: { from: 5, to: 10 } }, + }, + dispatch: mockDispatch, + } as unknown as EditorView, + }; + const { result } = renderWithProvider(() => + useCellEditorNavigationProps(mockCellId, mockEditorView), + ); + + const mockEvent = Mocks.keyboardEvent({ key: "Escape" }); + + act(() => { + result.current.onKeyDown?.(mockEvent); + }); + + const mockCall = mockDispatch.mock.calls[0][0]; + expect(mockCall.selection.ranges[0].anchor).toBe(5); + expect(mockCall.selection.ranges[0].head).toBe(5); + expect(focusCell).not.toHaveBeenCalled(); + }); + + it("should close autocomplete popup when Escape is pressed with popup active", () => { + const mockEditorView = { + current: { + state: { + selection: { main: { from: 5, to: 5 } }, + field: vi.fn().mockReturnValue({ active: [{ state: 1 }] }), // Mock active completion + }, + dispatch: vi.fn(), + } as unknown as EditorView, + }; + + // Mock the closeCompletion function + const originalCloseCompletion = vi.hoisted(() => vi.fn()); + vi.mock("@codemirror/autocomplete", () => ({ + completionStatus: vi.fn().mockReturnValue("active"), + closeCompletion: originalCloseCompletion, + })); + + const { result } = renderWithProvider(() => + useCellEditorNavigationProps(mockCellId, mockEditorView), + ); + + const mockEvent = Mocks.keyboardEvent({ key: "Escape" }); + + act(() => { + result.current.onKeyDown?.(mockEvent); + }); + + expect(originalCloseCompletion).toHaveBeenCalledWith( + mockEditorView.current, + ); + expect(focusCell).not.toHaveBeenCalled(); + }); + it("should continue propagation for other keys", () => { + const mockEditorView = { current: null }; const { result } = renderWithProvider(() => - useCellEditorNavigationProps(mockCellId), + useCellEditorNavigationProps(mockCellId, mockEditorView), ); const mockEvent = Mocks.keyboardEvent({ key: "Enter" }); @@ -1621,8 +1693,9 @@ describe("useCellEditorNavigationProps", () => { }); it("should focus cell when Ctrl+Escape is pressed in vim mode", () => { + const mockEditorView = { current: null }; const { result } = renderWithProvider(() => - useCellEditorNavigationProps(mockCellId), + useCellEditorNavigationProps(mockCellId, mockEditorView), ); const mockEvent = Mocks.keyboardEvent({ key: "Escape", ctrlKey: true }); @@ -1636,8 +1709,9 @@ describe("useCellEditorNavigationProps", () => { }); it("should focus cell when Cmd+Escape (metaKey) is pressed in vim mode", () => { + const mockEditorView = { current: null }; const { result } = renderWithProvider(() => - useCellEditorNavigationProps(mockCellId), + useCellEditorNavigationProps(mockCellId, mockEditorView), ); const mockEvent = Mocks.keyboardEvent({ key: "Escape", metaKey: true }); @@ -1651,8 +1725,9 @@ describe("useCellEditorNavigationProps", () => { }); it("should not focus cell when Escape (without Ctrl) is pressed in vim mode", () => { + const mockEditorView = { current: null }; const { result } = renderWithProvider(() => - useCellEditorNavigationProps(mockCellId), + useCellEditorNavigationProps(mockCellId, mockEditorView), ); const mockEvent = Mocks.keyboardEvent({ key: "Escape" }); diff --git a/frontend/src/components/editor/navigation/navigation.ts b/frontend/src/components/editor/navigation/navigation.ts index c98dc72b861..8057937d8a6 100644 --- a/frontend/src/components/editor/navigation/navigation.ts +++ b/frontend/src/components/editor/navigation/navigation.ts @@ -1,5 +1,7 @@ /* Copyright 2024 Marimo. All rights reserved. */ +import { closeCompletion, completionStatus } from "@codemirror/autocomplete"; +import { EditorSelection } from "@codemirror/state"; import type { EditorView } from "@codemirror/view"; import { useAtomValue, useSetAtom, useStore } from "jotai"; import { mergeProps, useFocusWithin, useKeyboard } from "react-aria"; @@ -600,23 +602,61 @@ export function useCellNavigationProps( * * Handles both keyboard and mouse navigation. */ -export function useCellEditorNavigationProps(cellId: CellId) { +export function useCellEditorNavigationProps( + cellId: CellId, + editorView: React.RefObject, +) { const setTemporarilyShownCode = useSetAtom(temporarilyShownCodeAtom); const keymapPreset = useAtomValue(keymapPresetAtom); + const exitToCommandMode = () => { + setTemporarilyShownCode(false); + focusCell(cellId); + }; + + const handleEscape = () => { + // If there is a text selection or autocomplete popup in the editor, we clear those and return. + // Subsequent 'Escapes' will exit to command mode. + + if (!editorView.current) { + // If no editor, we can exit to command mode immediately + exitToCommandMode(); + return; + } + + const view = editorView.current; + const state = view.state; + + const hasTextSelection = + state.selection.main.from !== state.selection.main.to; + + if (hasTextSelection) { + view.dispatch({ + selection: EditorSelection.single(state.selection.main.from), // Cursor to the start of the selection + }); + return; + } + + const hasAutocompletePopup = completionStatus(state) !== null; + if (hasAutocompletePopup) { + closeCompletion(view); + return; + } + + exitToCommandMode(); + }; + const { keyboardProps } = useKeyboard({ onKeyDown: (evt) => { // For vim mode, require Ctrl+Escape (or Cmd+Escape on Mac) to exit to command mode if (keymapPreset === "vim") { if (evt.key === "Escape" && (evt.ctrlKey || evt.metaKey)) { - setTemporarilyShownCode(false); - focusCell(cellId); + handleEscape(); } } else { // For non-vim mode, regular Escape exits to command mode if (evt.key === "Escape") { - setTemporarilyShownCode(false); - focusCell(cellId); + handleEscape(); } } diff --git a/frontend/src/components/ui/combobox.tsx b/frontend/src/components/ui/combobox.tsx index fb9eea8d9f2..446b273c2cb 100644 --- a/frontend/src/components/ui/combobox.tsx +++ b/frontend/src/components/ui/combobox.tsx @@ -175,8 +175,8 @@ export const Combobox = ({ )} aria-expanded={open} > - {renderValue()}{" "} - + {renderValue()} + { ); }); +const pyrightClient = once((_: LSPConfig) => { + const lspClientOpts = { + transport: createTransport("basedpyright"), + rootUri: getLSPDocumentRootUri(), + workspaceFolders: [], + }; + + // We wrap the client in a NotebookLanguageServerClient to add some + // additional functionality to handle multiple cells + return new NotebookLanguageServerClient( + new LanguageServerClient({ + ...lspClientOpts, + autoClose: false, + }), + {}, + ); +}); + /** * Language adapter for Python. */ @@ -209,6 +227,9 @@ export class PythonLanguageAdapter implements LanguageAdapter<{}> { if (lspConfig?.ty?.enabled && hasCapability("ty")) { clients.push(tyLspClient(lspConfig)); } + if (lspConfig?.basedpyright?.enabled && hasCapability("basedpyright")) { + clients.push(pyrightClient(lspConfig)); + } if (clients.length > 0) { const client = diff --git a/frontend/src/core/codemirror/lsp/transports.ts b/frontend/src/core/codemirror/lsp/transports.ts index 87998e83f87..b539cda89b9 100644 --- a/frontend/src/core/codemirror/lsp/transports.ts +++ b/frontend/src/core/codemirror/lsp/transports.ts @@ -12,7 +12,9 @@ import { getRuntimeManager } from "../../runtime/config"; * @param serverName - The name of the LSP server. * @returns The transport. */ -export function createTransport(serverName: "pylsp" | "copilot" | "ty") { +export function createTransport( + serverName: "pylsp" | "basedpyright" | "copilot" | "ty", +) { const runtimeManager = getRuntimeManager(); const transport = new WebSocketTransport( runtimeManager.getLSPURL(serverName).toString(), diff --git a/frontend/src/core/config/capabilities.ts b/frontend/src/core/config/capabilities.ts index 5d87102f70c..c046149eb33 100644 --- a/frontend/src/core/config/capabilities.ts +++ b/frontend/src/core/config/capabilities.ts @@ -6,6 +6,7 @@ import { store } from "../state/jotai"; export const capabilitiesAtom = atom({ terminal: false, pylsp: false, + basedpyright: false, ty: false, }); diff --git a/frontend/src/core/runtime/runtime.ts b/frontend/src/core/runtime/runtime.ts index 40ee2ab5ed8..01216b6fd5b 100644 --- a/frontend/src/core/runtime/runtime.ts +++ b/frontend/src/core/runtime/runtime.ts @@ -135,7 +135,7 @@ export class RuntimeManager { /** * The URL of the copilot server. */ - getLSPURL(lsp: "pylsp" | "copilot" | "ty"): URL { + getLSPURL(lsp: "pylsp" | "basedpyright" | "copilot" | "ty"): URL { if (lsp === "copilot") { // For copilot, don't include any query parameters const url = this.formatWsURL(`/lsp/${lsp}`); diff --git a/frontend/src/plugins/impl/SearchableSelect.tsx b/frontend/src/plugins/impl/SearchableSelect.tsx index 175ed443ea1..924f92c2612 100644 --- a/frontend/src/plugins/impl/SearchableSelect.tsx +++ b/frontend/src/plugins/impl/SearchableSelect.tsx @@ -105,7 +105,9 @@ export const SearchableSelect = (props: SearchableSelectProps): JSX.Element => { }} placeholder="Select..." multiple={false} - className={cn("w-full", { "w-full": fullWidth })} + className={cn({ + "w-full": fullWidth, + })} value={value ?? NONE_KEY} onValueChange={handleValueChange} shouldFilter={false} diff --git a/frontend/src/plugins/impl/data-frames/forms/__tests__/__snapshots__/form.test.tsx.snap b/frontend/src/plugins/impl/data-frames/forms/__tests__/__snapshots__/form.test.tsx.snap index 824dfe26e94..11322820044 100644 --- a/frontend/src/plugins/impl/data-frames/forms/__tests__/__snapshots__/form.test.tsx.snap +++ b/frontend/src/plugins/impl/data-frames/forms/__tests__/__snapshots__/form.test.tsx.snap @@ -36,10 +36,14 @@ exports[`renderZodSchema > should render a form aggregate 1`] = ` data-state="closed" type="button" > - Select columns + + Select columns + + Select aggregations + + Select columns + + Select columns + + Select columns + + Select columns + + Select columns + + -- + Install basedpyright for type checking support.", + variant="danger", + ) + + class TyServer(BaseLspServer): id = "ty" @@ -284,6 +326,7 @@ def is_running(self) -> bool: class CompositeLspServer(LspServer): LANGUAGE_SERVERS = { "pylsp": PyLspServer, + "basedpyright": BasedpyrightServer, "ty": TyServer, "copilot": CopilotLspServer, } diff --git a/packages/openapi/api.yaml b/packages/openapi/api.yaml index fdc2dee21d0..854e0026797 100644 --- a/packages/openapi/api.yaml +++ b/packages/openapi/api.yaml @@ -1280,6 +1280,8 @@ components: type: object capabilities: properties: + basedpyright: + type: boolean pylsp: type: boolean terminal: @@ -1290,6 +1292,7 @@ components: - terminal - pylsp - ty + - basedpyright type: object cell_ids: items: @@ -1638,6 +1641,11 @@ components: type: object language_servers: properties: + basedpyright: + properties: + enabled: + type: boolean + type: object pylsp: properties: enable_flake8: @@ -2741,7 +2749,7 @@ components: type: object info: title: marimo API - version: 0.14.13 + version: 0.14.15 openapi: 3.1.0 paths: /@file/{filename_and_length}: diff --git a/packages/openapi/package.json b/packages/openapi/package.json index d117b9e4ed0..45b3d3a91e9 100644 --- a/packages/openapi/package.json +++ b/packages/openapi/package.json @@ -16,7 +16,7 @@ "codegen:notebook": "openapi-typescript ../../marimo/_schemas/generated/notebook.yaml -o ./src/notebook.ts" }, "devDependencies": { - "openapi-typescript": "^7.3.0" + "openapi-typescript": "^7.8.0" }, "dependencies": { "openapi-fetch": "0.9.7" diff --git a/packages/openapi/src/api.ts b/packages/openapi/src/api.ts index 0ee3bd9af75..987ea8a5106 100644 --- a/packages/openapi/src/api.ts +++ b/packages/openapi/src/api.ts @@ -3063,6 +3063,7 @@ export interface components { width: "normal" | "compact" | "medium" | "full" | "columns"; }; capabilities: { + basedpyright: boolean; pylsp: boolean; terminal: boolean; ty: boolean; @@ -3231,6 +3232,9 @@ export interface components { vimrc?: string | null; }; language_servers?: { + basedpyright?: { + enabled?: boolean; + }; pylsp?: { enable_flake8?: boolean; enable_mypy?: boolean; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 93cba549ebe..371bf0ca9a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -668,7 +668,7 @@ importers: version: 0.9.7 devDependencies: openapi-typescript: - specifier: ^7.3.0 + specifier: ^7.8.0 version: 7.8.0(typescript@5.8.3) packages: @@ -8172,6 +8172,7 @@ packages: source-map@0.8.0-beta.0: resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==} engines: {node: '>= 8'} + deprecated: The work that was done in this beta branch won't be included in future versions space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} diff --git a/pyproject.toml b/pyproject.toml index ca71c8c7ed2..7b2bf853ba6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "uv_build" [project] name = "marimo" -version = "0.14.15" +version = "0.14.16" description = "A library for making reactive notebooks and apps" # We try to keep dependencies to a minimum, to avoid conflicts with # user environments;we need a very compelling reason for each dependency added. @@ -546,7 +546,7 @@ platforms = ["osx-arm64", "linux-64"] [tool.pixi.dependencies] nodejs = "22.*" pnpm = "10.*" -hatch = ">=1.14.0,<2" +hatch = ">=1.14.1,<2" make = ">=4.4.1,<5" pre_commit = ">=4.2.0,<5" uv = ">=0.6.12,<0.7" diff --git a/scripts/generate_ipynb_fixtures.py b/scripts/generate_ipynb_fixtures.py index 6b0955037e7..20f07979246 100644 --- a/scripts/generate_ipynb_fixtures.py +++ b/scripts/generate_ipynb_fixtures.py @@ -156,6 +156,17 @@ def main() -> None: ], ) + create_notebook_fixture( + "pip_commands", + [ + "!pip install transformers", + "!pip install pandas numpy matplotlib", + "# Mixed cell with pip and other commands\n!pip install scikit-learn\nimport numpy as np\n!pip install seaborn", + "# Non-pip exclamation commands should remain unchanged\n!ls -la\n!echo 'Hello World'", + "# Magic pip command should also be handled\n%pip install requests", + ], + ) + if __name__ == "__main__": main() diff --git a/tests/_convert/ipynb_data/pip_commands.ipynb b/tests/_convert/ipynb_data/pip_commands.ipynb new file mode 100644 index 00000000000..63f02c0897b --- /dev/null +++ b/tests/_convert/ipynb_data/pip_commands.ipynb @@ -0,0 +1,63 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "0", + "metadata": {}, + "outputs": [], + "source": [ + "!pip install transformers" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "!pip install pandas numpy matplotlib" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2", + "metadata": {}, + "outputs": [], + "source": [ + "# Mixed cell with pip and other commands\n", + "!pip install scikit-learn\n", + "import numpy as np\n", + "!pip install seaborn" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "# Non-pip exclamation commands should remain unchanged\n", + "!ls -la\n", + "!echo 'Hello World'" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", + "metadata": {}, + "outputs": [], + "source": [ + "# Magic pip command should also be handled\n", + "%pip install requests" + ] + } + ], + "metadata": {}, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/tests/_convert/snapshots/convert_pip_commands.py.txt b/tests/_convert/snapshots/convert_pip_commands.py.txt new file mode 100644 index 00000000000..d4a568552f6 --- /dev/null +++ b/tests/_convert/snapshots/convert_pip_commands.py.txt @@ -0,0 +1,45 @@ +import marimo + +app = marimo.App() + + +@app.cell +def _(): + # (use marimo's built-in package management features instead) !pip install transformers + return + + +@app.cell +def _(): + # (use marimo's built-in package management features instead) !pip install pandas numpy matplotlib + return + + +@app.cell +def _(): + # Mixed cell with pip and other commands + # (use marimo's built-in package management features instead) !pip install scikit-learn + import numpy as np + # (use marimo's built-in package management features instead) !pip install seaborn + return + + +app._unparsable_cell( + r""" + # Non-pip exclamation commands should remain unchanged + !ls -la + !echo 'Hello World' + """, + name="_" +) + + +@app.cell +def _(): + # Magic pip command should also be handled + # '%pip install requests' command supported automatically in marimo + return + + +if __name__ == "__main__": + app.run() diff --git a/tests/_data/test_preview_column.py b/tests/_data/test_preview_column.py index e86f1e596e4..ad386cb3ee1 100644 --- a/tests/_data/test_preview_column.py +++ b/tests/_data/test_preview_column.py @@ -493,3 +493,23 @@ def test_sanitize_dtypes() -> None: result = _sanitize_dtypes(nw_df, "int128_col") assert result.schema["int128_col"] == nw.Int64 + + +@pytest.mark.skipif( + not DependencyManager.narwhals.has(), reason="narwhals not installed" +) +@pytest.mark.xfail(reason="Sanitizing is failing") # TODO: Fix this +def test_sanitize_dtypes_enum() -> None: + import narwhals as nw + import polars as pl + + df = pl.DataFrame( + { + "enum_col": ["A", "B", "A"], + }, + schema={"enum_col": pl.Enum(["A", "B"])}, + ) + nw_df = nw.from_native(df) + + result = _sanitize_dtypes(nw_df, "enum_col") + assert result.schema["enum_col"] == nw.String diff --git a/tests/_runtime/packages/test_pypi_package_manager.py b/tests/_runtime/packages/test_pypi_package_manager.py index b77f5272f3f..7b1589c2386 100644 --- a/tests/_runtime/packages/test_pypi_package_manager.py +++ b/tests/_runtime/packages/test_pypi_package_manager.py @@ -216,6 +216,37 @@ async def test_uv_install_not_in_project(mock_run: MagicMock): assert result is True +@patch("subprocess.run") +@patch.object(UvPackageManager, "is_in_uv_project", False) +async def test_uv_install_not_in_project_with_target(mock_run: MagicMock): + """Test UV install uses pip with target""" + mock_run.return_value = MagicMock(returncode=0) + mgr = UvPackageManager() + + # Explicitly set environ, since patch doesn't work in an asynchronous + # context. + import os + + os.environ["MARIMO_UV_TARGET"] = "target_path" + result = await mgr._install("package1 package2", upgrade=False) + del os.environ["MARIMO_UV_TARGET"] + + mock_run.assert_called_once_with( + [ + "uv", + "pip", + "install", + "--target=target_path", + "--compile", + "package1", + "package2", + "-p", + PY_EXE, + ], + ) + assert result is True + + @patch("subprocess.run") @patch.object(UvPackageManager, "is_in_uv_project", True) async def test_uv_install_in_project(mock_run: MagicMock): diff --git a/tests/_save/decorator_imports/module_0/__init__.py b/tests/_save/decorator_imports/module_0/__init__.py new file mode 100644 index 00000000000..6c8e6b979c5 --- /dev/null +++ b/tests/_save/decorator_imports/module_0/__init__.py @@ -0,0 +1 @@ +__version__ = "0.0.0" diff --git a/tests/_save/decorator_imports/module_1/__init__.py b/tests/_save/decorator_imports/module_1/__init__.py new file mode 100644 index 00000000000..5becc17c04a --- /dev/null +++ b/tests/_save/decorator_imports/module_1/__init__.py @@ -0,0 +1 @@ +__version__ = "1.0.0" diff --git a/tests/_save/decorator_imports/transitive_imports.py b/tests/_save/decorator_imports/transitive_imports.py new file mode 100644 index 00000000000..5edfdbc3f5f --- /dev/null +++ b/tests/_save/decorator_imports/transitive_imports.py @@ -0,0 +1,38 @@ +import marimo + +__generated_with = "0.14.15" +app = marimo.App(width="medium") + +with app.setup: + import math + + import marimo as mo + import tests._save.decorator_imports.module_1 as my_module + + +@app.function +@mo.cache +def has_import(): + return len([mo]) + + +@app.function +@mo.cache +def doesnt_have_import(): + return len([mo, math]) + + +@app.function +@mo.cache(pin_modules=True) +def doesnt_have_namespace_pinned() -> None: + return my_module.__version__ + + +@app.function +@mo.cache +def doesnt_have_namespace() -> None: + return my_module.__version__ + + +if __name__ == "__main__": + app.run() diff --git a/tests/_save/test_cache.py b/tests/_save/test_cache.py index 9c883dea5ca..09cd88fdf49 100644 --- a/tests/_save/test_cache.py +++ b/tests/_save/test_cache.py @@ -1953,19 +1953,19 @@ def test_execution_hash_same_block_fails() -> None: app._anonymous_file = True @app.cell - def __(): + def _(): import marimo as mo return (mo,) @app.cell - def __(): + def _(): import weakref return (weakref,) @app.cell - def __(mo, weakref): + def _(mo, weakref): class Namespace: ... ns = Namespace() @@ -1982,7 +1982,7 @@ def f(): ) @app.cell - def __(f): + def _(f): f() return @@ -2229,6 +2229,29 @@ def __(v): assert v == 3 return + @staticmethod + def test_cache_with_mutation_after_def(app) -> None: + @app.cell + def __(): + import marimo as mo + + return (mo,) + + @app.cell + def _(mo): + arr = [1, 2, 3] + + @mo.cache + def g(): + return len(arr) + + assert g() == 3 + arr.append(4) # Mutation after definition + assert g() == 4 + arr = [1, 2] # Mutation after definition + assert g() == 2 + return (g, arr) + class TestPersistentCache: async def test_pickle_context( diff --git a/tests/_save/test_decorator_imports.py b/tests/_save/test_decorator_imports.py new file mode 100644 index 00000000000..5cf6135d1a2 --- /dev/null +++ b/tests/_save/test_decorator_imports.py @@ -0,0 +1,132 @@ +# Copyright 2024 Marimo. All rights reserved. +from __future__ import annotations + +import sys +import textwrap + +from marimo._runtime.requests import ExecutionRequest +from marimo._runtime.runtime import Kernel +from tests.conftest import ExecReqProvider + + +def test_has_shared_import(app) -> None: + with app.setup: + import marimo as mo + from tests._save.decorator_imports.transitive_imports import has_import + + @app.cell + def has_dep_works() -> tuple[int]: + # matches test + use mo for lint + assert has_import() == len([mo]) + + +def test_doesnt_have_shared_import(app) -> None: + with app.setup: + from tests._save.decorator_imports.transitive_imports import ( + doesnt_have_import, + ) + + @app.cell + def doesnt_have_dep_works() -> tuple[int]: + # Counts modules on call. + assert doesnt_have_import() == 2 + + +def test_has_dep_with_differing_name_works(app) -> None: + for module in list(sys.modules.keys()): + if module.startswith("tests._save.decorator_imports"): + del sys.modules[module] + + with app.setup: + import marimo as mo + import tests._save.decorator_imports.module_0 as my_module + from tests._save.decorator_imports.transitive_imports import ( + doesnt_have_namespace as other, + doesnt_have_namespace_pinned as other_pinned, + ) + + @app.function + @mo.cache(pin_modules=True) + def doesnt_have_namespace_pinned() -> None: + return my_module.__version__ + + @app.function + @mo.cache + def doesnt_have_namespace() -> None: + return my_module.__version__ + + @app.cell + def has_dep_with_differing_name_works() -> tuple[int]: + assert other() != my_module.__version__ + other_hash = other._last_hash + assert doesnt_have_namespace() == my_module.__version__ + # By virtue of backwards compatibility, this is true. + # TODO: Negate and fix. + assert other_hash == doesnt_have_namespace._last_hash + + @app.cell + def has_dep_with_differing_name_works_pinned() -> tuple[int]: + assert other_pinned() != my_module.__version__ + other_hash_pinned = other_pinned._last_hash + assert doesnt_have_namespace_pinned() == my_module.__version__ + assert other_hash_pinned != doesnt_have_namespace_pinned._last_hash + + +async def test_decorator_in_kernel( + lazy_kernel: Kernel, exec_req: ExecReqProvider +) -> None: + k = lazy_kernel + await k.run( + [ + ExecutionRequest( + cell_id="setup", + code=textwrap.dedent( + """ + import marimo as mo + import tests._save.decorator_imports.module_0 as my_module + from tests._save.decorator_imports.transitive_imports import ( + doesnt_have_namespace as other, + doesnt_have_namespace_pinned as other_pinned, + ) + from tests._save.decorator_imports.transitive_imports import ( + doesnt_have_import, + ) + from tests._save.decorator_imports.transitive_imports import has_import + """ + ), + ), + exec_req.get( + """ + @mo.cache(pin_modules=True) + def doesnt_have_namespace_pinned() -> None: + return my_module.__version__ + """ + ), + exec_req.get( + """ + @mo.cache + def doesnt_have_namespace() -> None: + return my_module.__version__ + """ + ), + exec_req.get( + """ + assert has_import() == 1 + assert doesnt_have_import() == 2 + assert other() != my_module.__version__ + other_hash = other._last_hash + assert doesnt_have_namespace() == my_module.__version__ + # By virtue of backwards compatibility, this is true. + # TODO: Negate and fix. + assert other_hash == doesnt_have_namespace._last_hash + + assert other_pinned() != my_module.__version__ + other_hash_pinned = other_pinned._last_hash + assert doesnt_have_namespace_pinned() == my_module.__version__ + assert other_hash_pinned != doesnt_have_namespace_pinned._last_hash + resolved = True + """ + ), + ] + ) + assert k.globals.get("resolved", False), k.stderr diff --git a/tests/_server/test_lsp.py b/tests/_server/test_lsp.py index c0da9a2fc8c..14103ef8047 100644 --- a/tests/_server/test_lsp.py +++ b/tests/_server/test_lsp.py @@ -149,9 +149,13 @@ def as_reader( with mock.patch("marimo._server.lsp.DependencyManager") as mock_dm: mock_dm.pylsp = mock.MagicMock() mock_dm.pylsp.has.return_value = True - total_lsp_servers = 3 + total_lsp_servers = 4 config = LanguageServersConfig( - {"pylsp": {"enabled": True}, "ty": {"enabled": True}} + { + "pylsp": {"enabled": True}, + "ty": {"enabled": True}, + "basedpyright": {"enabled": True}, + } ) completion_config = CompletionConfig( {"copilot": True, "activate_on_typing": True} @@ -164,6 +168,7 @@ def as_reader( assert server._is_enabled("pylsp") is True assert server._is_enabled("copilot") is True assert server._is_enabled("ty") is True + assert server._is_enabled("basedpyright") is True # Test with only pylsp config = LanguageServersConfig({"pylsp": {"enabled": True}}) @@ -179,7 +184,11 @@ def as_reader( # Test with only ty enabled config = LanguageServersConfig( - {"ty": {"enabled": True}, "pylsp": {"enabled": False}} + { + "ty": {"enabled": True}, + "pylsp": {"enabled": False}, + "basedpyright": {"enabled": False}, + } ) completion_config = CompletionConfig( {"copilot": False, "activate_on_typing": True} @@ -188,9 +197,29 @@ def as_reader( server = CompositeLspServer(config_reader, min_port=8000) assert len(server.servers) == total_lsp_servers assert server._is_enabled("pylsp") is False + assert server._is_enabled("basedpyright") is False assert server._is_enabled("copilot") is False assert server._is_enabled("ty") is True + # Test with only basedpyright enabled + config = LanguageServersConfig( + { + "basedpyright": {"enabled": True}, + "pylsp": {"enabled": False}, + "ty": {"enabled": False}, + } + ) + completion_config = CompletionConfig( + {"copilot": False, "activate_on_typing": True} + ) + config_reader = as_reader(completion_config, config) + server = CompositeLspServer(config_reader, min_port=8000) + assert len(server.servers) == total_lsp_servers + assert server._is_enabled("pylsp") is False + assert server._is_enabled("basedpyright") is True + assert server._is_enabled("copilot") is False + assert server._is_enabled("ty") is False + # Test with nothing enabled config = LanguageServersConfig({"pylsp": {"enabled": False}}) completion_config = CompletionConfig(