Skip to content

Commit 8fa3956

Browse files
committed
Run notebook sandboxes with Pixi (#10697)
This PR remains draft until a Pixi release includes [prefix-dev/pixi#6648](prefix-dev/pixi#6648). The required upstream change has merged. The notebook-owned lifecycle in the previous PR describes operations in terms of a manifest and its realized environment. This PR supplies those operations with Pixi and makes the choice explicit at the CLI: ```console marimo edit --sandbox notebook.py # uv marimo edit --sandbox=pixi notebook.py # pixi ``` ```text NotebookSandbox(source, "pixi") -> PixiBackendAdapter -> add / remove / sync / packages / launch ``` Pixi reads the same PEP 723 metadata. Package changes use Pixi script commands, synchronization recovers the interpreter and environment root from structured output, and package inspection reads `pixi list` JSON. Launches layer the running marimo through `pixi exec`, so the shared manifest does not pin marimo to the machine that opened it. Selecting one manager does not import or probe the other. Pixi rejects Python interpreter overrides, which its script environments cannot honor, and falls back cleanly for app-host notebooks without script metadata. CLI, adapter, and fixture tests cover both managers; the guide documents the remaining Pixi limitations.
1 parent 3d68a73 commit 8fa3956

46 files changed

Lines changed: 3127 additions & 281 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/guides/package_management/inlining_dependencies.md

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,49 @@ uv run notebook.py
5151

5252
!!! note "Requires uv"
5353

54-
Sandboxed notebooks require the uv package manager
54+
The default `--sandbox` backend requires the uv package manager
5555
([installation
5656
instructions](https://docs.astral.sh/uv/getting-started/installation/)).
5757

58+
## Pixi sandboxes
59+
60+
If your notebook needs conda packages, use Pixi as the sandbox backend.
61+
[Install Pixi](https://pixi.prefix.dev/latest/installation/) with support for
62+
`pixi install --script` (available in Pixi 0.80.0). marimo checks this capability
63+
at startup; use `pixi self-update` if your installation predates it.
64+
65+
```bash
66+
marimo edit --sandbox=pixi notebook.py
67+
```
68+
69+
Pixi sandboxes resolve conda dependencies (declared under `[tool.pixi]`
70+
tables in the inline metadata) alongside PyPI dependencies, and require
71+
only pixi — uv is fetched through `pixi exec` when needed. A pixi
72+
notebook also runs standalone with `pixi run --script notebook.py`.
73+
74+
```python
75+
# /// script
76+
# dependencies = ["marimo", "polars"]
77+
#
78+
# [tool.pixi.workspace]
79+
# channels = ["conda-forge"]
80+
#
81+
# [tool.pixi.dependencies]
82+
# gdal = "*"
83+
# ///
84+
```
85+
86+
!!! warning "Pixi limitations"
87+
88+
- Pixi activation scripts and activation environment variables are not
89+
applied when marimo launches the notebook sandbox.
90+
- `marimo export html-wasm --execute` runs notebooks under
91+
[Pyodide](https://pyodide.org)'s package set, which has no conda
92+
equivalent: notebooks that rely on `[tool.pixi.dependencies]`
93+
cannot be exported to WASM.
94+
- The `--sandbox` flag on `marimo export` commands always uses uv,
95+
so conda dependencies are not available during export.
96+
5897
!!! tip "Solving the notebook reproducibility crisis"
5998

6099
marimo's support for package sandboxing is only possible because marimo
@@ -276,5 +315,3 @@ pyproject: |
276315
- altair==<version>
277316
---
278317
```
279-
280-

frontend/src/__mocks__/requests.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,8 +136,13 @@ export const MockRequestClient = {
136136
updateCellOutputs: vi.fn().mockResolvedValue({}),
137137
addPackage: vi.fn().mockResolvedValue({}),
138138
removePackage: vi.fn().mockResolvedValue({}),
139-
getPackageList: vi.fn().mockResolvedValue({ packages: [] }),
140-
getDependencyTree: vi.fn().mockResolvedValue({}),
139+
getPackageList: vi.fn().mockResolvedValue({
140+
packages: [],
141+
}),
142+
getDependencyTree: vi.fn().mockResolvedValue({
143+
tree: null,
144+
context: { kind: "package-manager", name: "pip" },
145+
}),
141146
listSecretKeys: vi.fn().mockResolvedValue({ keys: [] }),
142147
writeSecret: vi.fn().mockResolvedValue({}),
143148
invokeAiTool: vi.fn().mockResolvedValue({}),
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
/* Copyright 2026 Marimo. All rights reserved. */
2+
3+
import { fireEvent, render, screen } from "@testing-library/react";
4+
import { Provider } from "jotai";
5+
import { beforeEach, describe, expect, it, vi } from "vitest";
6+
import { MockRequestClient } from "@/__mocks__/requests";
7+
import { TooltipProvider } from "@/components/ui/tooltip";
8+
import { requestClientAtom } from "@/core/network/requests";
9+
import type {
10+
DependencyTreeNode,
11+
DependencyTreeResponse,
12+
} from "@/core/network/types";
13+
import { store } from "@/core/state/jotai";
14+
import PackagesPanel from "../packages-panel";
15+
16+
const { openSettings } = vi.hoisted(() => ({
17+
openSettings: vi.fn(),
18+
}));
19+
20+
vi.mock("@/components/app-config/state", () => ({
21+
useOpenSettingsToTab: () => ({ handleClick: openSettings }),
22+
}));
23+
24+
const emptyTree: DependencyTreeNode = {
25+
name: "<root>",
26+
version: null,
27+
tags: [],
28+
dependencies: [],
29+
};
30+
31+
const populatedTree: DependencyTreeNode = {
32+
...emptyTree,
33+
dependencies: [
34+
{
35+
name: "polars",
36+
version: "1.44.1",
37+
tags: [],
38+
dependencies: [
39+
{
40+
name: "numpy",
41+
version: "2.5.2",
42+
tags: [{ kind: "dedupe", value: "true" }],
43+
dependencies: [],
44+
},
45+
],
46+
},
47+
],
48+
};
49+
50+
function renderPanel(
51+
context: DependencyTreeResponse["context"],
52+
tree: DependencyTreeNode = emptyTree,
53+
) {
54+
const getPackageList = vi.fn().mockResolvedValue({ packages: [] });
55+
store.set(
56+
requestClientAtom,
57+
MockRequestClient.create({
58+
getPackageList,
59+
getDependencyTree: vi.fn().mockResolvedValue({ tree, context }),
60+
}),
61+
);
62+
63+
return {
64+
getPackageList,
65+
...render(
66+
<Provider store={store}>
67+
<TooltipProvider>
68+
<PackagesPanel />
69+
</TooltipProvider>
70+
</Provider>,
71+
),
72+
};
73+
}
74+
75+
describe("PackagesPanel", () => {
76+
beforeEach(() => {
77+
vi.clearAllMocks();
78+
});
79+
80+
it.each(["pixi", "uv"] as const)(
81+
"shows the effective %s sandbox backend as a fixed context",
82+
async (backend) => {
83+
const { getPackageList } = renderPanel({ kind: "sandbox", backend });
84+
85+
expect(
86+
await screen.findByPlaceholderText(
87+
`Add packages to ${backend} sandbox...`,
88+
),
89+
).toBeInTheDocument();
90+
expect(screen.getByText(`${backend} sandbox`)).toBeInTheDocument();
91+
expect(
92+
screen.queryByRole("button", { name: "Change package manager" }),
93+
).not.toBeInTheDocument();
94+
expect(
95+
screen.queryByRole("button", { name: "List" }),
96+
).not.toBeInTheDocument();
97+
expect(
98+
screen.queryByRole("button", { name: "Tree" }),
99+
).not.toBeInTheDocument();
100+
expect(getPackageList).not.toHaveBeenCalled();
101+
},
102+
);
103+
104+
it("explains the filtered empty state for a Pixi sandbox", async () => {
105+
renderPanel({ kind: "sandbox", backend: "pixi" });
106+
107+
expect(await screen.findByText("No PyPI dependencies")).toBeInTheDocument();
108+
expect(
109+
screen.getByText("Conda dependencies are not shown in this panel."),
110+
).toBeInTheDocument();
111+
});
112+
113+
it("labels a dependency whose subtree was already displayed", async () => {
114+
renderPanel({ kind: "sandbox", backend: "pixi" }, populatedTree);
115+
116+
fireEvent.click(await screen.findByRole("treeitem", { name: /polars/ }));
117+
118+
expect(screen.getByText("already in tree")).toBeInTheDocument();
119+
expect(screen.queryByText("cycle")).not.toBeInTheDocument();
120+
});
121+
122+
it("keeps the configured package manager changeable outside a sandbox", async () => {
123+
const { getPackageList } = renderPanel({
124+
kind: "package-manager",
125+
name: "pip",
126+
});
127+
128+
expect(
129+
await screen.findByPlaceholderText("Install packages with pip..."),
130+
).toBeInTheDocument();
131+
const changeManager = screen.getByRole("button", {
132+
name: "Change package manager",
133+
});
134+
135+
fireEvent.click(changeManager);
136+
137+
expect(openSettings).toHaveBeenCalledWith("packageManagementAndData");
138+
expect(screen.getByText("environment")).toBeInTheDocument();
139+
expect(screen.getByRole("button", { name: "List" })).toBeInTheDocument();
140+
expect(screen.getByRole("button", { name: "Tree" })).toBeInTheDocument();
141+
expect(getPackageList).toHaveBeenCalledOnce();
142+
});
143+
});

0 commit comments

Comments
 (0)