Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions marimo/_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,8 @@ def edit(
# Enable script metadata management for sandboxed notebooks
os.environ["MARIMO_MANAGE_SCRIPT_METADATA"] = "true"
GLOBAL_SETTINGS.MANAGE_SCRIPT_METADATA = True
os.environ["MARIMO_SANDBOX_MODE"] = "multi"
GLOBAL_SETTINGS.SANDBOX_MODE = "multi"

# Check shared memory availability early (required for edit mode to
# communicate between the server process and kernel subprocess)
Expand Down
9 changes: 8 additions & 1 deletion marimo/_cli/sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -473,12 +473,19 @@ def cleanup_constraint_file() -> None:
if name is not None and not overridden and os.path.isfile(name):
try:
handle = environment.sync_notebook(
name, python_override=python_request
name,
python_override=python_request,
# Stream uv's progress to the terminal; a first
# synchronization can install for a while.
on_output=lambda _line: None,
)
echo(
f"Using script environment: {muted(handle.root)}",
err=True,
)
# Only a server whose kernel runs in the script environment
# may route package changes through it.
env["MARIMO_SANDBOX_MODE"] = "single"
plan = environment.launch(
handle, cmd, overlay=overlay, base_env=env
)
Expand Down
4 changes: 4 additions & 0 deletions marimo/_config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from __future__ import annotations

import logging
import os
from dataclasses import dataclass

from marimo._utils.env import is_env_true
Expand All @@ -17,6 +18,9 @@ class GlobalSettings:
PROFILE_DIR: str | None = None
LOG_LEVEL: int = logging.WARNING
MANAGE_SCRIPT_METADATA: bool = is_env_true("MARIMO_MANAGE_SCRIPT_METADATA")
# "single" or "multi" when this process belongs to a sandbox whose
# dependencies live in script environments; None otherwise.
SANDBOX_MODE: str | None = os.environ.get("MARIMO_SANDBOX_MODE") or None
IN_SECURE_ENVIRONMENT: bool = is_env_true("MARIMO_IN_SECURE_ENVIRONMENT")
# Mark the session cookie as `Secure` so browsers only send it over HTTPS.
# Enable when serving marimo behind TLS / a TLS-terminating proxy. Default
Expand Down
34 changes: 21 additions & 13 deletions marimo/_environments/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,15 @@
import msgspec

from marimo import _loggers
from marimo._environments.uv import UvError, require_uv_bin, uv
from marimo._environments.uv import (
UvError,
require_uv_bin,
uv,
uv_stream,
)

if TYPE_CHECKING:
from collections.abc import Mapping, Sequence
from collections.abc import Callable, Mapping, Sequence

LOGGER = _loggers.marimo_logger()

Expand Down Expand Up @@ -181,13 +186,15 @@ def sync(
*,
cwd: str | None = None,
python_override: str | None = None,
on_output: Callable[[str], None] | None = None,
) -> Environment:
"""Makes the script's environment match its metadata.

Runs uv from `cwd`, normally the notebook's directory, so
directory-scoped uv configuration applies. `python_override` wins
over the script's `requires-python` (html-wasm export pins the
Pyodide interpreter). Raises `UvCommandError` on failure and never
Pyodide interpreter). With `on_output`, uv's progress streams to the
callback line by line. Raises `UvCommandError` on failure and never
mutates `script`.
"""
ensure_supported_uv()
Expand All @@ -201,12 +208,18 @@ def sync(
]
if python_override is not None:
args.extend(["--python", python_override])
completed = uv(args, env=_sync_env(), cwd=cwd)
if on_output is not None:
completed = uv_stream(args, on_output, env=_sync_env(), cwd=cwd)
else:
completed = uv(args, env=_sync_env(), cwd=cwd)
return _parse_report(completed.stdout)


def sync_notebook(
path: str, *, python_override: str | None = None
path: str,
*,
python_override: str | None = None,
on_output: Callable[[str], None] | None = None,
) -> Environment:
"""Synchronizes a notebook's script environment.

Expand All @@ -223,6 +236,7 @@ def sync_notebook(
target.path,
cwd=target.directory,
python_override=python_override,
on_output=on_output,
)


Expand Down Expand Up @@ -335,12 +349,6 @@ def _venv_bin_dir(root: str) -> str:


def _sync_env() -> dict[str, str]:
"""Environment for `uv sync --script` invocations.
from marimo._environments.uv import script_command_env

Script environments are selected by the script and its metadata; an
enclosing project or virtualenv must not redirect synchronization.
"""
env = dict(os.environ)
env.pop("VIRTUAL_ENV", None)
env.pop("UV_PROJECT_ENVIRONMENT", None)
return env
return script_command_env()
52 changes: 39 additions & 13 deletions marimo/_environments/script_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,12 @@

from marimo import _loggers
from marimo._environments.errors import EnvironmentManagerError
from marimo._environments.uv import UvError, uv
from marimo._environments.uv import (
UvError,
script_command_env,
uv,
uv_stream,
)
from marimo._utils.toml import toml_reader

if TYPE_CHECKING:
Expand Down Expand Up @@ -98,32 +103,48 @@ def with_python_version_requirement(project: dict[str, Any]) -> dict[str, Any]:


def add_dependencies(
path: str, packages: Sequence[str], *, upgrade: bool = False
path: str,
packages: Sequence[str],
*,
upgrade: bool = False,
on_output: Callable[[str], None] | None = None,
) -> None:
"""Add packages to the script's metadata via `uv add --script`."""
if not packages:
return
args = ["--quiet", "add", "--script"]
args = ["add", "--script"]

def edit(target: str, cwd: str) -> None:
uv(
[*args, target, *(["--upgrade"] if upgrade else []), *packages],
cwd=cwd,
)
command = [
*args,
target,
*(["--upgrade"] if upgrade else []),
*packages,
]
if on_output is not None:
uv_stream(command, on_output, env=script_command_env(), cwd=cwd)
else:
uv(["--quiet", *command], env=script_command_env(), cwd=cwd)

_edit(path, edit)


def remove_dependencies(path: str, packages: Sequence[str]) -> None:
def remove_dependencies(
path: str,
packages: Sequence[str],
*,
on_output: Callable[[str], None] | None = None,
) -> None:
"""Remove packages from the script's metadata via `uv remove --script`."""
if not packages:
return

def edit(target: str, cwd: str) -> None:
uv(
["--quiet", "remove", "--script", target, *packages],
cwd=cwd,
)
command = ["remove", "--script", target, *packages]
if on_output is not None:
uv_stream(command, on_output, env=script_command_env(), cwd=cwd)
else:
uv(["--quiet", *command], env=script_command_env(), cwd=cwd)

_edit(path, edit)

Expand All @@ -148,7 +169,12 @@ def ensure_marimo(path: str) -> None:
return

def edit(target: str, cwd: str) -> None:
uv(["add", "--script", target, "marimo"], timeout=30, cwd=cwd)
uv(
["add", "--script", target, "marimo"],
env=script_command_env(),
timeout=30,
cwd=cwd,
)

_edit(path, edit)

Expand Down
100 changes: 99 additions & 1 deletion marimo/_environments/uv.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,17 @@
import os
import shutil
import subprocess
import sys
import threading
from typing import TYPE_CHECKING

from marimo import _loggers
from marimo._environments.errors import EnvironmentManagerError

if TYPE_CHECKING:
from collections.abc import Mapping, Sequence
from collections.abc import Callable, Mapping, Sequence

LOGGER = _loggers.marimo_logger()

UV_INSTALL_HINT = (
"Install uv from https://docs.astral.sh/uv/getting-started/installation/"
Expand Down Expand Up @@ -166,3 +171,96 @@ def uv(
if completed.returncode != 0:
raise _refine(completed)
return completed


def uv_stream(
args: Sequence[str],
on_output: Callable[[str], None],
*,
env: Mapping[str, str] | None = None,
cwd: str | None = None,
) -> subprocess.CompletedProcess[str]:
"""Runs a uv command, streaming its diagnostics to a callback.

uv writes progress to stderr and machine-readable output to stdout:
stderr lines stream to `on_output` (and this process's stderr) while
stdout is captured for the caller. `on_output` runs in the calling
thread, so callbacks that rely on thread-local state, such as a
kernel's notification context, keep working. Failures raise the same
refined errors as `uv()`.
"""
command = [find_uv_bin(), *args]
try:
process = subprocess.Popen(
command,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
stdin=subprocess.DEVNULL,
env=dict(env) if env is not None else None,
cwd=cwd,
bufsize=0,
# A kernel interrupt must not propagate to uv; run it in its
# own session (ignored on Windows).
start_new_session=True,
)
except FileNotFoundError as e:
raise UvNotFoundError() from e

assert process.stdout is not None
assert process.stderr is not None
stdout_pipe = process.stdout
stdout_chunks: list[bytes] = []

def drain_stdout() -> None:
stdout_chunks.append(stdout_pipe.read())
stdout_pipe.close()

reader = threading.Thread(target=drain_stdout, daemon=True)
reader.start()

stderr_lines: list[bytes] = []
for line in iter(process.stderr.readline, b""):
stderr_lines.append(line)
decoded = line.decode("utf-8", errors="replace")
# The terminal tee is best effort: a kernel replaces sys.stderr
# with a redirect whose `buffer` may be None, and nothing here
# may stop the stream or deadlock uv.
try:
buffer = getattr(sys.stderr, "buffer", None)
if buffer is not None:
buffer.write(line)
buffer.flush()
else:
sys.stderr.write(decoded)
except Exception:
pass
try:
on_output(decoded)
except Exception:
LOGGER.exception("Failed to stream uv output")
process.stderr.close()
returncode = process.wait()
reader.join()

completed = subprocess.CompletedProcess(
command,
returncode,
stdout=b"".join(stdout_chunks).decode("utf-8", errors="replace"),
stderr=b"".join(stderr_lines).decode("utf-8", errors="replace"),
)
if completed.returncode != 0:
raise _refine(completed)
return completed


def script_command_env() -> dict[str, str]:
"""Environment for uv script commands.

A script's environment is selected by the script and its metadata; an
enclosing project or virtualenv must not redirect it or warn about
the mismatch.
"""
env = dict(os.environ)
env.pop("VIRTUAL_ENV", None)
env.pop("UV_PROJECT_ENVIRONMENT", None)
return env
Loading
Loading