diff --git a/marimo/_cli/development/commands.py b/marimo/_cli/development/commands.py index e12dcabcea9..325e341aef7 100644 --- a/marimo/_cli/development/commands.py +++ b/marimo/_cli/development/commands.py @@ -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, diff --git a/marimo/_export/dependencies.py b/marimo/_export/dependencies.py index c9cb073f9d0..ca107e8f2cd 100644 --- a/marimo/_export/dependencies.py +++ b/marimo/_export/dependencies.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +import subprocess import sys from typing import cast @@ -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]", @@ -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]: diff --git a/marimo/_runtime/packages/pypi_package_manager.py b/marimo/_runtime/packages/pypi_package_manager.py index 0dd6ca6f769..e067aa61b8d 100644 --- a/marimo/_runtime/packages/pypi_package_manager.py +++ b/marimo/_runtime/packages/pypi_package_manager.py @@ -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: @@ -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}") @@ -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 @@ -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: diff --git a/marimo/_schemas/export.py b/marimo/_schemas/export.py index e73aec45b0c..faa36e07741 100644 --- a/marimo/_schemas/export.py +++ b/marimo/_schemas/export.py @@ -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] diff --git a/marimo/_server/api/endpoints/editing.py b/marimo/_server/api/endpoints/editing.py index e07e7d3cc4c..5bc53faa4db 100644 --- a/marimo/_server/api/endpoints/editing.py +++ b/marimo/_server/api/endpoints/editing.py @@ -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, @@ -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. diff --git a/marimo/_server/api/endpoints/export.py b/marimo/_server/api/endpoints/export.py index 70ed514d349..2f7917bb8a0 100644 --- a/marimo/_server/api/endpoints/export.py +++ b/marimo/_server/api/endpoints/export.py @@ -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, @@ -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, @@ -49,6 +51,7 @@ ExportAsScriptRequest, ExportAvailabilityResponse, ExportFormatAvailability, + InstallExportRequirementsRequest, UpdateCellOutputsRequest, to_html_export_options, to_ipynb_export_options, @@ -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, ) @@ -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( @@ -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") diff --git a/marimo/_server/api/utils.py b/marimo/_server/api/utils.py index a93999a972b..fc639de065a 100644 --- a/marimo/_server/api/utils.py +++ b/marimo/_server/api/utils.py @@ -260,7 +260,6 @@ 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. @@ -268,18 +267,34 @@ async def install_packages_on_server( 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( diff --git a/packages/openapi/api.yaml b/packages/openapi/api.yaml index 9723adc3dd3..30a87e7a242 100644 --- a/packages/openapi/api.yaml +++ b/packages/openapi/api.yaml @@ -2526,6 +2526,19 @@ components: - msg title: ImportStarError type: object + InstallExportRequirementsRequest: + properties: + format: + enum: + - html + - ipynb + - markdown + - pdf + - script + required: + - format + title: InstallExportRequirementsRequest + type: object InstallPackagesCommand: description: "Install Python packages.\n\n Installs missing packages using\ \ the specified package manager. Triggered\n automatically on import errors\ @@ -6710,6 +6723,26 @@ paths: description: File must be saved before downloading 500: description: Export failed or dependencies missing + /api/export/requirements/install: + post: + parameters: + - in: header + name: Marimo-Session-Id + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/InstallExportRequirementsRequest' + responses: + 200: + content: + application/json: + schema: + $ref: '#/components/schemas/ExportAvailabilityResponse' + description: Updated readiness for server-backed exports /api/export/script: post: parameters: diff --git a/packages/openapi/src/api.ts b/packages/openapi/src/api.ts index 38d8a84e8af..20249e364d1 100644 --- a/packages/openapi/src/api.ts +++ b/packages/openapi/src/api.ts @@ -1124,6 +1124,47 @@ export interface paths { patch?: never; trace?: never; }; + "/api/export/requirements/install": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: { + parameters: { + query?: never; + header: { + "Marimo-Session-Id": string; + }; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["InstallExportRequirementsRequest"]; + }; + }; + responses: { + /** @description Updated readiness for server-backed exports */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ExportAvailabilityResponse"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/export/script": { parameters: { query?: never; @@ -5147,6 +5188,11 @@ export interface components { /** @enum {unknown} */ type: "import-star"; }; + /** InstallExportRequirementsRequest */ + InstallExportRequirementsRequest: { + /** @enum {unknown} */ + format: "html" | "ipynb" | "markdown" | "pdf" | "script"; + }; /** * InstallPackagesCommand * @description Install Python packages. diff --git a/tests/_export/test_export_availability.py b/tests/_export/test_export_availability.py index 523a58f1d20..359f5f2cdd5 100644 --- a/tests/_export/test_export_availability.py +++ b/tests/_export/test_export_availability.py @@ -2,9 +2,10 @@ from __future__ import annotations import asyncio +import subprocess import sys from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import ANY, AsyncMock, MagicMock, patch import pytest @@ -16,6 +17,7 @@ _is_playwright_chromium_installed, get_missing_export_packages, get_missing_export_setup, + install_export_setup, require_export_dependencies, ) from marimo._export.exporter import Exporter @@ -77,6 +79,52 @@ def test_ipynb_requirement_uses_nbformat() -> None: assert get_missing_export_packages("ipynb") == ["nbformat"] +async def test_install_export_setup_uses_server_python() -> None: + completed = subprocess.CompletedProcess([], 0, "", "") + with patch( + "marimo._export.dependencies.subprocess.run", + return_value=completed, + ) as run: + await install_export_setup("playwright-chromium") + + run.assert_called_once_with( + [sys.executable, "-m", "playwright", "install", "chromium"], + capture_output=True, + text=True, + timeout=ANY, + ) + assert run.call_args.kwargs["timeout"] > 0 + + +async def test_install_export_setup_reports_failure() -> None: + completed = subprocess.CompletedProcess([], 1, "", "install failed") + with ( + patch( + "marimo._export.dependencies.subprocess.run", + return_value=completed, + ), + pytest.raises( + RuntimeError, + match="Playwright Chromium installation failed", + ), + ): + await install_export_setup("playwright-chromium") + + +async def test_install_export_setup_reports_timeout() -> None: + with ( + patch( + "marimo._export.dependencies.subprocess.run", + side_effect=subprocess.TimeoutExpired("playwright", timeout=1), + ), + pytest.raises( + RuntimeError, + match="Playwright Chromium installation failed", + ), + ): + await install_export_setup("playwright-chromium") + + @pytest.mark.parametrize( ("chromium_installed", "missing_setup"), [ diff --git a/tests/_runtime/packages/test_pypi_package_manager.py b/tests/_runtime/packages/test_pypi_package_manager.py index a9673c25576..16d033c8fe8 100644 --- a/tests/_runtime/packages/test_pypi_package_manager.py +++ b/tests/_runtime/packages/test_pypi_package_manager.py @@ -1,9 +1,11 @@ from __future__ import annotations +import asyncio import json import os import subprocess import sys +import threading from functools import partial from pathlib import Path from typing import Any @@ -349,6 +351,20 @@ def test_uv_is_in_uv_project_true(mock_exists: MagicMock): assert mgr.is_in_uv_project is True +@patch.object(UvPackageManager, "_uv_bin", "uv") +def test_uv_can_target_python_without_mutating_project() -> None: + mgr = UvPackageManager.for_pip_install("/server/python") + + assert mgr.install_command("nbformat", upgrade=False) == [ + "uv", + "pip", + "install", + "nbformat", + "-p", + "/server/python", + ] + + @patch.dict( "os.environ", {"VIRTUAL_ENV": "/path/to/venv", "UV": "/path/to/venv"}, @@ -396,6 +412,32 @@ async def test_uv_install_not_in_project(mock_popen: MagicMock): assert result is True +@patch.object(UvPackageManager, "is_in_uv_project", False) +async def test_uv_install_does_not_block_event_loop() -> None: + process_started = asyncio.Event() + release_process = threading.Event() + loop = asyncio.get_running_loop() + + def install(*args: Any, **kwargs: Any) -> bool: + del args, kwargs + loop.call_soon_threadsafe(process_started.set) + release_process.wait(timeout=1) + return True + + mgr = UvPackageManager() + with patch.object( + mgr, "_install_with_cache_fallback", side_effect=install + ): + task = asyncio.create_task( + mgr._install("package1", upgrade=False, group=None) + ) + await process_started.wait() + assert not task.done() + + release_process.set() + assert await task is True + + @patch("marimo._utils.subprocess.subprocess.Popen") @patch.object(UvPackageManager, "is_in_uv_project", False) async def test_uv_install_not_in_project_with_target(mock_popen: MagicMock): diff --git a/tests/_server/api/endpoints/test_editing.py b/tests/_server/api/endpoints/test_editing.py index 42376c87b64..8657bc6c534 100644 --- a/tests/_server/api/endpoints/test_editing.py +++ b/tests/_server/api/endpoints/test_editing.py @@ -1,7 +1,6 @@ # Copyright 2026 Marimo. All rights reserved. from __future__ import annotations -import sys from typing import TYPE_CHECKING from unittest.mock import AsyncMock, MagicMock, patch @@ -124,19 +123,13 @@ def test_install_missing_packages(client: TestClient) -> None: assert "success" in response.json() -def _mock_server_install( - client: TestClient, *, manager_installed: bool = True -) -> tuple[MagicMock, MagicMock]: - """POST install_missing_packages with source="server" using a mocked - package manager. Returns (mock_create, mock_pkg_manager).""" - mock_pkg_manager = MagicMock() - mock_pkg_manager.is_manager_installed.return_value = manager_installed - mock_pkg_manager.install = AsyncMock() - +@with_session(SESSION_ID) +def test_install_missing_packages_server_source(client: TestClient) -> None: + install_packages = AsyncMock() with patch( - "marimo._runtime.packages.package_managers.create_package_manager", - return_value=mock_pkg_manager, - ) as mock_create: + "marimo._server.api.endpoints.editing.install_packages_on_server", + new=install_packages, + ): response = client.post( "/api/kernel/install_missing_packages", headers=HEADERS, @@ -149,27 +142,7 @@ def _mock_server_install( assert response.status_code == 200, response.text assert "success" in response.json() - return mock_create, mock_pkg_manager - - -@with_session(SESSION_ID) -def test_install_missing_packages_server_source(client: TestClient) -> None: - # source="server" routes the install to the server's Python env directly - # rather than dispatching to the kernel. - mock_create, mock_pkg_manager = _mock_server_install(client) - mock_create.assert_called_once_with("pip", python_exe=sys.executable) - mock_pkg_manager.install.assert_awaited_once_with("nbformat", version=None) - - -@with_session(SESSION_ID) -def test_install_missing_packages_server_source_manager_not_installed( - client: TestClient, -) -> None: - # When the package manager is not installed, alert_not_installed is called - # and no packages are installed. - _, mock_pkg_manager = _mock_server_install(client, manager_installed=False) - mock_pkg_manager.alert_not_installed.assert_called_once() - mock_pkg_manager.install.assert_not_awaited() + install_packages.assert_awaited_once_with({"nbformat": ""}) @with_session(SESSION_ID) diff --git a/tests/_server/api/endpoints/test_export.py b/tests/_server/api/endpoints/test_export.py index eec35280d1e..4c3bc83a0c3 100644 --- a/tests/_server/api/endpoints/test_export.py +++ b/tests/_server/api/endpoints/test_export.py @@ -22,10 +22,19 @@ from marimo._messaging.cell_output import CellChannel, CellOutput from marimo._messaging.notification import CellNotification from marimo._output.utils import uri_encode_component -from marimo._schemas.export_options import PDFExportOptions +from marimo._schemas.export import ( + ExportAvailabilityResponse, + ExportFormatAvailability, + ExportSetupRequirement, +) +from marimo._schemas.export_options import ( + PDFExportOptions, + ServerExportFormat, +) from marimo._session.model import SessionMode from marimo._session.notebook.file_manager import AppFileManager from marimo._types.ids import CellId_t, SessionId +from marimo._utils.http import HTTPException from marimo._utils.platform import is_windows from tests._server.mocks import ( get_session_manager, @@ -36,6 +45,7 @@ from tests.mocks import EDGE_CASE_FILENAMES, snapshotter if TYPE_CHECKING: + from httpx import Response from starlette.testclient import TestClient snapshot = snapshotter(__file__) @@ -49,6 +59,37 @@ CODE = uri_encode_component("import marimo as mo") +def _format_availability( + export_format: ServerExportFormat, + *, + missing_packages: tuple[str, ...] = (), + missing_setup: tuple[ExportSetupRequirement, ...] = (), +) -> ExportFormatAvailability: + return ExportFormatAvailability( + format=export_format, + dependencies_available=not missing_packages and not missing_setup, + missing_packages=list(missing_packages), + missing_setup=list(missing_setup), + ) + + +def _availability( + *formats: ExportFormatAvailability, +) -> ExportAvailabilityResponse: + return ExportAvailabilityResponse(source="server", formats=list(formats)) + + +def _install_export_requirements( + client: TestClient, + export_format: ServerExportFormat, +) -> Response: + return client.post( + "/api/export/requirements/install", + headers=HEADERS, + json={"format": export_format}, + ) + + def _ipynb_export_app() -> InternalApp: app = App() @@ -246,6 +287,156 @@ def test_export_availability_handles_pdf_setup_probe_failure( ) +@with_session(SESSION_ID) +def test_install_export_requirements_resolves_server_requirements( + client: TestClient, +) -> None: + setup = ExportSetupRequirement( + name="playwright-chromium", + command="uv run playwright install chromium", + ) + install_packages = AsyncMock() + + async def assert_packages_installed_first(requirement: str) -> None: + assert requirement == "playwright-chromium" + install_packages.assert_awaited_once_with({"nbconvert[webpdf]": ""}) + + install_setup = AsyncMock(side_effect=assert_packages_installed_first) + refreshed = _availability( + _format_availability("ipynb"), + _format_availability("pdf"), + ) + + with ( + patch( + "marimo._server.api.endpoints.export._get_export_format_availability", + new=AsyncMock( + side_effect=[ + _format_availability( + "pdf", + missing_packages=("nbconvert[webpdf]",), + ), + _format_availability( + "pdf", + missing_setup=(setup,), + ), + ] + ), + ), + patch( + "marimo._server.api.endpoints.export.install_packages_on_server", + new=install_packages, + ), + patch( + "marimo._server.api.endpoints.export.install_export_setup", + new=install_setup, + ), + patch( + "marimo._server.api.endpoints.export._get_export_availability", + new=AsyncMock(return_value=refreshed), + ), + ): + response = _install_export_requirements(client, "pdf") + + assert response.status_code == 200, response.text + install_setup.assert_awaited_once_with("playwright-chromium") + assert all( + item["dependenciesAvailable"] for item in response.json()["formats"] + ) + + +@with_session(SESSION_ID) +def test_install_export_requirements_is_idempotent( + client: TestClient, +) -> None: + install_packages = AsyncMock() + install_setup = AsyncMock() + with ( + patch( + "marimo._server.api.endpoints.export._get_export_format_availability", + new=AsyncMock(return_value=_format_availability("markdown")), + ), + patch( + "marimo._server.api.endpoints.export.install_packages_on_server", + new=install_packages, + ), + patch( + "marimo._server.api.endpoints.export.install_export_setup", + new=install_setup, + ), + patch( + "marimo._server.api.endpoints.export._get_export_availability", + new=AsyncMock( + return_value=_availability(_format_availability("markdown")) + ), + ), + ): + response = _install_export_requirements(client, "markdown") + + assert response.status_code == 200, response.text + install_packages.assert_not_awaited() + install_setup.assert_not_awaited() + + +@with_session(SESSION_ID) +def test_install_export_requirements_fails_when_unresolved( + client: TestClient, +) -> None: + install_packages = AsyncMock() + missing = _format_availability("ipynb", missing_packages=("nbformat",)) + with ( + patch( + "marimo._server.api.endpoints.export._get_export_format_availability", + new=AsyncMock(return_value=missing), + ), + patch( + "marimo._server.api.endpoints.export.install_packages_on_server", + new=install_packages, + ), + patch( + "marimo._server.api.endpoints.export._get_export_availability", + new=AsyncMock(return_value=_availability(missing)), + ), + ): + response = _install_export_requirements(client, "ipynb") + + assert response.status_code == 500 + assert response.json() == { + "detail": ( + "Failed to install requirements for IPYNB export. " + "Check the server logs." + ) + } + install_packages.assert_awaited_once_with({"nbformat": ""}) + + +@with_session(SESSION_ID) +def test_install_export_requirements_enforces_consumer_capability( + client: TestClient, +) -> None: + install_packages = AsyncMock() + install_setup = AsyncMock() + with ( + patch( + "marimo._server.api.endpoints.export.enforce_consumer_capability", + side_effect=HTTPException(status_code=403, detail="read-only"), + ), + patch( + "marimo._server.api.endpoints.export.install_packages_on_server", + new=install_packages, + ), + patch( + "marimo._server.api.endpoints.export.install_export_setup", + new=install_setup, + ), + ): + response = _install_export_requirements(client, "pdf") + + assert response.status_code == 403, response.text + install_packages.assert_not_awaited() + install_setup.assert_not_awaited() + + @with_session(SESSION_ID) def test_export_html(client: TestClient) -> None: session = get_session_manager(client).get_session(SESSION_ID) diff --git a/tests/_server/api/test_api_utils.py b/tests/_server/api/test_api_utils.py index 5a2f39bb01c..57aee7298e1 100644 --- a/tests/_server/api/test_api_utils.py +++ b/tests/_server/api/test_api_utils.py @@ -1,8 +1,11 @@ # Copyright 2026 Marimo. All rights reserved. from __future__ import annotations +import asyncio +import threading from types import SimpleNamespace from typing import TYPE_CHECKING, cast +from unittest.mock import AsyncMock, patch import msgspec import pytest @@ -14,6 +17,7 @@ from marimo._server.api.utils import ( get_code_mode_credentials, + install_packages_on_server, parse_multipart_request, ) @@ -79,6 +83,78 @@ def test_parse_multipart_request_raises_on_missing_field() -> None: client.post("/test", data={"name": "marimo"}) +async def test_install_packages_on_server_uses_uv_when_available() -> None: + with ( + patch( + "marimo._runtime.packages.pypi_package_manager.UvPackageManager.is_manager_installed", + return_value=True, + ), + patch( + "marimo._runtime.packages.pypi_package_manager.UvPackageManager.install", + new=AsyncMock(return_value=True), + ) as install, + ): + await install_packages_on_server({"nbformat": ""}) + + install.assert_awaited_once_with("nbformat", version=None) + + +async def test_install_packages_on_server_reports_install_failure() -> None: + with ( + patch( + "marimo._runtime.packages.pypi_package_manager.UvPackageManager.is_manager_installed", + return_value=False, + ), + patch( + "marimo._runtime.packages.pypi_package_manager.PipPackageManager.is_manager_installed", + return_value=True, + ), + patch( + "marimo._runtime.packages.pypi_package_manager.PipPackageManager.install", + new=AsyncMock(return_value=False), + ), + pytest.raises( + RuntimeError, + match="Failed to install nbformat into the server Python", + ), + ): + await install_packages_on_server({"nbformat": ""}) + + +async def test_install_packages_on_server_checks_pip_off_event_loop() -> None: + check_started = asyncio.Event() + release_check = threading.Event() + loop = asyncio.get_running_loop() + + def check_pip() -> bool: + loop.call_soon_threadsafe(check_started.set) + release_check.wait(timeout=1) + return True + + with ( + patch( + "marimo._runtime.packages.pypi_package_manager.UvPackageManager.is_manager_installed", + return_value=False, + ), + patch( + "marimo._runtime.packages.pypi_package_manager.PipPackageManager.is_manager_installed", + side_effect=check_pip, + ), + patch( + "marimo._runtime.packages.pypi_package_manager.PipPackageManager.install", + new=AsyncMock(return_value=True), + ), + ): + install = asyncio.create_task( + install_packages_on_server({"nbformat": ""}) + ) + await check_started.wait() + assert not install.done() + + release_check.set() + await install + + def _fake_app_state( *, host: str = "localhost",