Repository: marimo-team/marimo
Pull request #5045: feat(sandbox): Prefer `uv export` for dumping requirements.txt
Author: login: manzt
Created: 2025-05-22T21:34:09Z | Merged: 2025-05-23T14:50:06Z

## 📝 Summary

Sandbox environments currently have trouble reusing cached virtual environments from `uv`, resulting in long startup times for packages like `pandas`.

From my testing, this appears to be caused by the way requirements are parsed and exported via `PyProjectReader`. The new `uv export --script` functionality allows us to defer requirements generation to uv, which seems to lead to better cache hits and improved reuse of environments.

https://github.com/user-attachments/assets/d3de1b57-b9b8-4b1a-845c-f7672457a4e9


## 🔍 Description of Changes

Rather than removing `PyProjectReader`, which is still useful in non-uv contexts (pyodide), this PR adds a shortcut: when `uv` is available, we use `uv export --script` to generate requirements.txt directly. If that fails for any reason, we fall back to the existing logic that extracts dependencies manually.

This change better aligns with how uv expects requirements to be structured and should reduce cold-start latency in sandboxed environments.

Note: This PR does not yet handle relative paths for local dependencies. I think we should address that separately by introducing a method to canonicalize relative paths during export.

~Putting this in draft for now. Some tests are failing and I’ll investigate when I get a chance.~

## 📋 Checklist

- [x] I have read the [contributor guidelines](https://github.com/marimo-team/marimo/blob/main/CONTRIBUTING.md).
- [ ] For large changes, or changes that affect the public API: this change was discussed or approved through an issue, on [Discord](https://marimo.io/discord?ref=pr), or the community [discussions](https://github.com/marimo-team/marimo/discussions) (Please provide a link if applicable).
- [ ] I have added tests for the changes made.
- [x] I have run the code and verified that it works as expected.

## 📜 Reviewers

@mscolnick



Diff:
diff --git a/marimo/_cli/sandbox.py b/marimo/_cli/sandbox.py
index ccf1e8318a1..fecfe497575 100644
--- a/marimo/_cli/sandbox.py
+++ b/marimo/_cli/sandbox.py
@@ -124,6 +124,38 @@ def include_features(dep: str, features: list[DepFeatures]) -> str:
     return filtered + [include_features(chosen, additional_features)]
 
 
+def _uv_export_script_requirements_txt(
+    name: str | None,
+) -> list[str]:
+    if not name:
+        return []
+
+    result = subprocess.run(
+        [
+            "uv",
+            "export",
+            "--no-hashes",
+            "--no-annotate",
+            "--no-header",
+            "--script",
+            name,
+        ],
+        check=True,
+        capture_output=True,
+        text=True,
+    )
+    return result.stdout.split("\n")
+
+
+def _resolve_requirements_txt_lines(pyproject: PyProjectReader) -> list[str]:
+    if pyproject.name and pyproject.name.endswith(".py"):
+        try:
+            return _uv_export_script_requirements_txt(pyproject.name)
+        except subprocess.CalledProcessError:
+            pass  # Fall back if uv fails
+    return pyproject.requirements_txt_lines
+
+
 def get_marimo_dir() -> Path:
     return Path(__file__).parent.parent.parent
 
@@ -137,7 +169,7 @@ def construct_uv_flags(
     # NB. Used in quarto plugin
 
     # If name if a filepath, parse the dependencies from the file
-    dependencies = pyproject.requirements_txt_lines
+    dependencies = _resolve_requirements_txt_lines(pyproject)
 
     # If there are no dependencies, which can happen for marimo new or
     # on marimo edit a_new_file.py, uv may use a cached venv, even though
@@ -150,9 +182,6 @@ def construct_uv_flags(
         dependencies, __version__, additional_features
     )
 
-    # Add additional dependencies
-    dependencies.extend(additional_deps)
-
     temp_file.write("\n".join(dependencies))
 
     # Construct base UV command
@@ -167,6 +196,10 @@ def construct_uv_flags(
         temp_file.name,
     ]
 
+    # Layer additional deps on top of the requirements
+    if len(additional_deps) > 0:
+        uv_flags.extend(["--with", ",".join(additional_deps)])
+
     # Add refresh
     if uv_needs_refresh:
         uv_flags.append("--refresh")
diff --git a/marimo/_utils/inline_script_metadata.py b/marimo/_utils/inline_script_metadata.py
index ebb025f1046..99e9a3e3c6d 100644
--- a/marimo/_utils/inline_script_metadata.py
+++ b/marimo/_utils/inline_script_metadata.py
@@ -13,20 +13,31 @@
 
 
 class PyProjectReader:
-    def __init__(self, project: dict[str, Any], *, config_path: str | None):
+    def __init__(
+        self,
+        project: dict[str, Any],
+        *,
+        config_path: str | None,
+        name: str | None = None,
+    ):
         self.project = project
         self.config_path = config_path
+        self.name = name
 
     @staticmethod
     def from_filename(name: str) -> PyProjectReader:
         return PyProjectReader(
-            _get_pyproject_from_filename(name) or {}, config_path=name
+            name=name,
+            project=_get_pyproject_from_filename(name) or {},
+            config_path=name,
         )
 
     @staticmethod
     def from_script(script: str) -> PyProjectReader:
         return PyProjectReader(
-            read_pyproject_from_script(script) or {}, config_path=None
+            project=read_pyproject_from_script(script) or {},
+            config_path=None,
+            name=None,
         )
 
     @property
diff --git a/tests/_cli/test_sandbox.py b/tests/_cli/test_sandbox.py
index 27ae4c4e0c8..61f5e6a0c25 100644
--- a/tests/_cli/test_sandbox.py
+++ b/tests/_cli/test_sandbox.py
@@ -294,15 +294,12 @@ def test_construct_uv_cmd_with_additional_deps() -> None:
         additional_deps=additional_deps,
     )
 
-    # Get the requirements file path from the command
-    req_file_index = uv_cmd.index("--with-requirements") + 1
-    req_file_path = uv_cmd[req_file_index]
+    # Get the additional (layered) dependencies
+    with_dependencies_index = uv_cmd.index("--with") + 1
+    with_dependencies = uv_cmd[with_dependencies_index]
 
-    # Read the requirements file to verify additional deps were added
-    with open(req_file_path) as f:
-        requirements = f.read()
-        assert "numpy>=1.20.0" in requirements
-        assert "pandas" in requirements
+    assert "pandas" in with_dependencies
+    assert "numpy>=1.20.0" in with_dependencies
 
 
 def test_markdown_sandbox(tmp_path: Path) -> None:


Reviews:
user: login: mscolnick
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T21:36:44Z
user: login: mscolnick
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T21:37:59Z
user: login: manzt
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-22T21:38:59Z
user: login: manzt
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-22T21:46:30Z
user: login: mscolnick
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T22:11:00Z
user: login: manzt
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-22T22:36:08Z
user: login: manzt
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-22T23:07:06Z
user: login: mscolnick
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T14:49:13Z
user: login: mscolnick
body: this is great, glad we don't need to handle the `uv.sources` and whatever other new semantics get added in future. 

thanks for adding the fallback in case people are still on older versions of `uv` still
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T14:49:58Z

Review comments:
diff_hunk: @@ -163,6 +188,9 @@ def construct_uv_flags(
         "--no-project",
         # trade installation time for faster start time
         "--compile-bytecode",
+        # layer additional deps on top of the requirements
+        "--with",
+        ",".join(additional_deps),
path: marimo/_cli/sandbox.py
user: login: mscolnick
body: possibly double check if `additional_deps` is empty?
created_at: 2025-05-22T21:36:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -137,7 +160,12 @@ def construct_uv_flags(
     # NB. Used in quarto plugin
 
     # If name if a filepath, parse the dependencies from the file
-    dependencies = pyproject.requirements_txt_lines
+    try:
+        # Try exporting env with uv
+        dependencies = _uv_export_script_requirements_txt(pyproject.name)
path: marimo/_cli/sandbox.py
user: login: mscolnick
body: this could be a pyproject.toml (i think), so we could do a light check that it ends with `.py`
created_at: 2025-05-22T21:37:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -163,6 +188,9 @@ def construct_uv_flags(
         "--no-project",
         # trade installation time for faster start time
         "--compile-bytecode",
+        # layer additional deps on top of the requirements
+        "--with",
+        ",".join(additional_deps),
path: marimo/_cli/sandbox.py
user: login: manzt
body: Good idea.
created_at: 2025-05-22T21:38:59Z
author_association: COLLABORATOR
diff_hunk: @@ -137,7 +160,12 @@ def construct_uv_flags(
     # NB. Used in quarto plugin
 
     # If name if a filepath, parse the dependencies from the file
-    dependencies = pyproject.requirements_txt_lines
+    try:
+        # Try exporting env with uv
+        dependencies = _uv_export_script_requirements_txt(pyproject.name)
path: marimo/_cli/sandbox.py
user: login: manzt
body: When would that happen?

```sh
marimo edit . --sandbox
``` 

?
created_at: 2025-05-22T21:46:30Z
author_association: COLLABORATOR
diff_hunk: @@ -137,7 +160,12 @@ def construct_uv_flags(
     # NB. Used in quarto plugin
 
     # If name if a filepath, parse the dependencies from the file
-    dependencies = pyproject.requirements_txt_lines
+    try:
+        # Try exporting env with uv
+        dependencies = _uv_export_script_requirements_txt(pyproject.name)
path: marimo/_cli/sandbox.py
user: login: mscolnick
body: yea exactly. you can start the sandbox without a script/file in mind. still ephemeral, but starts empty 
created_at: 2025-05-22T22:10:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -137,7 +160,12 @@ def construct_uv_flags(
     # NB. Used in quarto plugin
 
     # If name if a filepath, parse the dependencies from the file
-    dependencies = pyproject.requirements_txt_lines
+    try:
+        # Try exporting env with uv
+        dependencies = _uv_export_script_requirements_txt(pyproject.name)
path: marimo/_cli/sandbox.py
user: login: manzt
body: Looks like no args are: `None`

<img alt="image" src="https://github.com/user-attachments/assets/2dc9c9f6-416e-463c-ace3-fd1d303d1332" />

and dot is forwarded directly: `.`

<img alt="image" src="https://github.com/user-attachments/assets/6a49fea4-ff5f-46ca-a8b6-8ac6de0b840d" />

I'm happy to check for `.py` still, however, just FYI.
created_at: 2025-05-22T22:36:08Z
author_association: COLLABORATOR
diff_hunk: @@ -294,15 +294,12 @@ def test_construct_uv_cmd_with_additional_deps() -> None:
         additional_deps=additional_deps,
     )
 
-    # Get the requirements file path from the command
-    req_file_index = uv_cmd.index("--with-requirements") + 1
-    req_file_path = uv_cmd[req_file_index]
+    # Get the additional (layered) dependencies
+    with_dependencies_index = uv_cmd.index("--with") + 1
+    with_dependencies = uv_cmd[with_dependencies_index]
 
-    # Read the requirements file to verify additional deps were added
-    with open(req_file_path) as f:
-        requirements = f.read()
-        assert "numpy>=1.20.0" in requirements
-        assert "pandas" in requirements
+    assert "pandas" in with_dependencies
+    assert "numpy>=1.20.0" in with_dependencies
 
path: tests/_cli/test_sandbox.py
user: login: manzt
body: These additional dependencies are no longer in the requirements file, since we layer them into the environment with `uv run --with-requirements /tmp/requirements.txt --with numpy>=1.20.0,pandas`.
created_at: 2025-05-22T23:07:06Z
author_association: COLLABORATOR
diff_hunk: @@ -294,15 +294,12 @@ def test_construct_uv_cmd_with_additional_deps() -> None:
         additional_deps=additional_deps,
     )
 
-    # Get the requirements file path from the command
-    req_file_index = uv_cmd.index("--with-requirements") + 1
-    req_file_path = uv_cmd[req_file_index]
+    # Get the additional (layered) dependencies
+    with_dependencies_index = uv_cmd.index("--with") + 1
+    with_dependencies = uv_cmd[with_dependencies_index]
 
-    # Read the requirements file to verify additional deps were added
-    with open(req_file_path) as f:
-        requirements = f.read()
-        assert "numpy>=1.20.0" in requirements
-        assert "pandas" in requirements
+    assert "pandas" in with_dependencies
+    assert "numpy>=1.20.0" in with_dependencies
 
path: tests/_cli/test_sandbox.py
user: login: mscolnick
body: awesome, this is a great change. 


created_at: 2025-05-23T14:49:13Z
author_association: CONTRIBUTOR

PR comments:
user: login: vercel[bot]
created_at: 2025-05-22T21:34:14Z
body: [vc]: #FK0oyUFasAEGug83JvnB5xWMxpHBC4f3jXmo01dW9P8=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJtYXJpbW8tZG9jcyIsInJvb3REaXJlY3RvcnkiOiJkb2NzIiwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL21hcmltby9tYXJpbW8tZG9jcy9BZ1hWOGdOdXJNdWRESGFCYm5aM0J4cFg1SmRwIiwicHJldmlld1VybCI6IiIsIm5leHRDb21taXRTdGF0dXMiOiJGQUlMRUQiLCJsaXZlRmVlZGJhY2siOnsicmVzb2x2ZWQiOjAsInVucmVzb2x2ZWQiOjAsInRvdGFsIjowLCJsaW5rIjoiIn19XX0=
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **marimo-docs** | ❌ Failed ([Inspect](https://vercel.com/marimo/marimo-docs/AgXV8gNurMudDHaBbnZ3BxpX5Jdp)) |  |  | May 22, 2025 11:06pm |


author_association: NONE
user: login: manzt
created_at: 2025-05-22T21:55:23Z
body: A nice side effect of this change is that `uv export` already captures the full resolution semantics (including `uv.tool` metadata and PEP 723) so we don't have to worry about imperfectly replicating that logic in `PyProjectReader.requirements_txt_lines`, or keeping up with changes to uv's behavior.
author_association: COLLABORATOR
user: login: github-actions[bot]
created_at: 2025-05-23T14:52:48Z
body: 🚀 Development release published. You may be able to view the changes at https://marimo.app?v=0.13.12-dev28
author_association: NONE