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
1 change: 1 addition & 0 deletions marimo/_cli/development/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,7 @@ def _generate_server_api_schema() -> dict[str, Any]:
export.ExportAsIPYNBRequest,
export.ExportAsPDFRequest,
export.ExportAvailabilityResponse,
export.InstallExportRequirementsRequest,
export.UpdateCellOutputsRequest,
files.FileCreateMultipartRequest,
files.FileCreateRequest,
Expand Down
44 changes: 43 additions & 1 deletion marimo/_export/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from __future__ import annotations

import asyncio
import subprocess
import sys
from typing import cast

Expand All @@ -15,13 +16,17 @@
DependencyRequirement,
)
from marimo._schemas.export import ExportSetupRequirement
from marimo._schemas.export_options import ServerExportFormat
from marimo._schemas.export_options import (
ExportSetupRequirementName,
ServerExportFormat,
)
from marimo._utils.assert_never import assert_never
from marimo._utils.async_path import isfile

LOGGER = _loggers.marimo_logger()

_IPYNB_DEPENDENCIES = (DependencyManager.nbformat,)
_PLAYWRIGHT_INSTALL_TIMEOUT_SECONDS = 600
_PDF_DEPENDENCIES = (
DependencyRequirement(
package="nbconvert[webpdf]",
Expand Down Expand Up @@ -61,6 +66,43 @@ def get_missing_export_packages(
)


async def _install_playwright_chromium() -> None:
try:
result = await asyncio.to_thread(
subprocess.run,
[sys.executable, "-m", "playwright", "install", "chromium"],
capture_output=True,
text=True,
timeout=_PLAYWRIGHT_INSTALL_TIMEOUT_SECONDS,
)
except subprocess.TimeoutExpired:
LOGGER.error(
"Playwright Chromium installation timed out after %s seconds",
_PLAYWRIGHT_INSTALL_TIMEOUT_SECONDS,
)
raise RuntimeError(
"Playwright Chromium installation failed. Check the server logs."
) from None
if result.returncode != 0:
LOGGER.error(
"Failed to install Playwright Chromium: %s",
result.stderr or result.stdout,
)
raise RuntimeError(
"Playwright Chromium installation failed. Check the server logs."
)


async def install_export_setup(
requirement: ExportSetupRequirementName,
) -> None:
match requirement:
case "playwright-chromium":
await _install_playwright_chromium()
case _:
assert_never(requirement)


async def get_missing_export_setup(
export_format: ServerExportFormat,
) -> list[ExportSetupRequirement]:
Expand Down
37 changes: 33 additions & 4 deletions marimo/_runtime/packages/pypi_package_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,14 @@ class UvPackageManager(PypiPackageManager):
docs_url = "https://docs.astral.sh/uv/"

SCRIPT_METADATA_MARKER = "# /// script"
_use_project = True

@classmethod
def for_pip_install(cls, python_exe: str) -> UvPackageManager:
"""Target an interpreter without changing its uv project."""
manager = cls(python_exe=python_exe)
manager._use_project = False
return manager

@cached_property
def _uv_bin(self) -> str:
Expand Down Expand Up @@ -340,7 +348,24 @@ async def _install(
log_callback=log_callback,
)

# For uv pip install, try with output capture to enable fallback
import asyncio

return await asyncio.to_thread(
self._install_with_cache_fallback,
package,
upgrade=upgrade,
group=group,
log_callback=log_callback,
)

def _install_with_cache_fallback(
self,
package: str,
*,
upgrade: bool,
group: str | None,
log_callback: LogCallback | None,
) -> bool:
cmd = self.install_command(package, upgrade=upgrade, group=group)

LOGGER.info(f"Running command: {cmd}")
Expand Down Expand Up @@ -388,9 +413,10 @@ async def _install(
"\nRetrying with --no-cache due to cache write permission error...\n"
)

# Retry with --no-cache flag
cmd_with_no_cache = cmd + ["--no-cache"]
return await self.run(cmd_with_no_cache, log_callback=log_callback)
return self._run_sync(
cmd + ["--no-cache"],
log_callback=log_callback,
)

return False

Expand Down Expand Up @@ -592,6 +618,9 @@ def is_in_uv_project(self) -> bool:
we are in a temporary virtual environment (e.g. `uvx marimo edit` or `uv --with=marimo run marimo edit`)
or in the currently activated virtual environment (e.g. `uv venv`).
"""
if not self._use_project:
return False

# Check we have a virtual environment
venv_path = os.environ.get("VIRTUAL_ENV", None)
if not venv_path:
Expand Down
4 changes: 4 additions & 0 deletions marimo/_schemas/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,5 +119,9 @@ class ExportAvailabilityResponse(msgspec.Struct, rename="camel", frozen=True):
formats: list[ExportFormatAvailability]


class InstallExportRequirementsRequest(msgspec.Struct, rename="camel"):
format: ServerExportFormat


class UpdateCellOutputsRequest(msgspec.Struct, rename="camel"):
cell_ids_to_output: dict[CellId_t, MimeBundleTuple]
6 changes: 4 additions & 2 deletions marimo/_server/api/endpoints/editing.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from marimo._server.api.deps import AppState
from marimo._server.api.utils import (
dispatch_control_request,
enforce_consumer_capability,
install_packages_on_server,
notify_server_missing_packages,
parse_request,
Expand Down Expand Up @@ -256,8 +257,9 @@ async def install_missing_packages(request: Request) -> BaseResponse:
# Used when the server itself needs a package (e.g. nbformat for
# IPYNB auto-export when running with --sandbox).
app_state = AppState(request)
app_state.require_current_session()
await install_packages_on_server(cmd.manager, cmd.versions)
enforce_consumer_capability(app_state, cmd)
if cmd.versions:
await install_packages_on_server(cmd.versions)
return SuccessResponse()

# Default ("kernel"): dispatch to kernel via ZeroMQ control queue.
Expand Down
110 changes: 94 additions & 16 deletions marimo/_server/api/endpoints/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from marimo._export.dependencies import (
get_missing_export_packages,
get_missing_export_setup,
install_export_setup,
)
from marimo._export.exporter import (
AutoExporter,
Expand All @@ -41,6 +42,7 @@
)
from marimo._export.serialization import serialize_notebook_snapshot
from marimo._messaging.msgspec_encoder import asdict
from marimo._runtime.commands import InstallPackagesCommand
from marimo._schemas.export import (
ExportAsHTMLRequest,
ExportAsIPYNBRequest,
Expand All @@ -49,6 +51,7 @@
ExportAsScriptRequest,
ExportAvailabilityResponse,
ExportFormatAvailability,
InstallExportRequirementsRequest,
UpdateCellOutputsRequest,
to_html_export_options,
to_ipynb_export_options,
Expand All @@ -59,9 +62,12 @@
SERVER_EXPORT_FORMATS,
IPYNBExportOptions,
MarkdownExportOptions,
ServerExportFormat,
)
from marimo._server.api.deps import AppState
from marimo._server.api.utils import (
enforce_consumer_capability,
install_packages_on_server,
notify_server_missing_packages,
parse_request,
)
Expand All @@ -80,6 +86,33 @@
auto_exporter = AutoExporter()


async def _get_export_format_availability(
export_format: ServerExportFormat,
) -> ExportFormatAvailability:
missing_packages = get_missing_export_packages(export_format)
missing_setup = (
[]
if missing_packages
else await get_missing_export_setup(export_format)
)
return ExportFormatAvailability(
format=export_format,
dependencies_available=not missing_packages and not missing_setup,
missing_packages=missing_packages,
missing_setup=missing_setup,
)


async def _get_export_availability() -> ExportAvailabilityResponse:
return ExportAvailabilityResponse(
source="server",
formats=[
await _get_export_format_availability(export_format)
for export_format in SERVER_EXPORT_FORMATS
],
)


@router.get("/availability")
@requires("read")
async def get_export_availability(
Expand All @@ -96,24 +129,69 @@ async def get_export_availability(
$ref: "#/components/schemas/ExportAvailabilityResponse"
"""
del request
formats: list[ExportFormatAvailability] = []
for export_format in SERVER_EXPORT_FORMATS:
missing_packages = get_missing_export_packages(export_format)
missing_setup = (
[]
if missing_packages
else await get_missing_export_setup(export_format)
return await _get_export_availability()


@router.post("/requirements/install")
@requires("edit")
async def install_export_requirements(
request: Request,
) -> ExportAvailabilityResponse:
"""
parameters:
- in: header
name: Marimo-Session-Id
schema:
type: string
required: true
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/InstallExportRequirementsRequest"
responses:
200:
description: Updated readiness for server-backed exports
content:
application/json:
schema:
$ref: "#/components/schemas/ExportAvailabilityResponse"
"""
app_state = AppState(request)
body = await parse_request(request, cls=InstallExportRequirementsRequest)
command = InstallPackagesCommand(
manager=app_state.app_config_manager.package_manager,
versions={},
source="server",
)
enforce_consumer_capability(app_state, command)

format_availability = await _get_export_format_availability(body.format)
if format_availability.missing_packages:
await install_packages_on_server(
{package: "" for package in format_availability.missing_packages}
)
formats.append(
ExportFormatAvailability(
format=export_format,
dependencies_available=not missing_packages
and not missing_setup,
missing_packages=missing_packages,
missing_setup=missing_setup,
)
format_availability = await _get_export_format_availability(
body.format
)

# Setup can only be probed after its Python packages are importable.
for requirement in format_availability.missing_setup:
await install_export_setup(requirement.name)

availability = await _get_export_availability()
target = next(
item for item in availability.formats if item.format == body.format
)
if not target.dependencies_available:
raise HTTPException(
status_code=HTTPStatus.SERVER_ERROR,
detail=(
f"Failed to install requirements for "
f"{body.format.upper()} export. Check the server logs."
),
)
return ExportAvailabilityResponse(source="server", formats=formats)
return availability


@router.post("/html")
Expand Down
31 changes: 23 additions & 8 deletions marimo/_server/api/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,26 +260,41 @@ def open_url_in_browser(browser: str, url: str) -> None:


async def install_packages_on_server(
manager: str,
versions: dict[str, str],
) -> None:
"""Install packages into the server's own Python environment.

Used when the server itself needs a package (e.g. nbformat for
IPYNB auto-export when running with --sandbox).
"""
import asyncio
import sys

from marimo._runtime.packages.package_managers import (
create_package_manager,
from marimo._runtime.packages.package_manager import PackageManager
from marimo._runtime.packages.pypi_package_manager import (
PipPackageManager,
UvPackageManager,
)

pkg_manager = create_package_manager(manager, python_exe=sys.executable)
if not pkg_manager.is_manager_installed():
pkg_manager.alert_not_installed()
return
pip = PipPackageManager(python_exe=sys.executable)
uv = UvPackageManager.for_pip_install(sys.executable)
pkg_manager: PackageManager
if await asyncio.to_thread(uv.is_manager_installed):
pkg_manager = uv
elif await asyncio.to_thread(pip.is_manager_installed):
pkg_manager = pip
else:
raise RuntimeError(
"No package installer is available for the server Python."
)

for pkg, version in versions.items():
await pkg_manager.install(pkg, version=version or None)
installed = await pkg_manager.install(pkg, version=version or None)
if not installed:
raise RuntimeError(
f"Failed to install {pkg} into the server Python. "
"Check the server logs."
)


def notify_server_missing_packages(
Expand Down
Loading
Loading