From 7486318cefd283a416c406569f8482542433e702 Mon Sep 17 00:00:00 2001 From: Alex Ford Date: Mon, 15 Jun 2026 17:21:50 -0700 Subject: [PATCH 1/5] Stub in initial .ipynb format support. --- marimo/_convert/ipynb/to_ir.py | 6 + marimo/_server/api/endpoints/editing.py | 3 +- marimo/_server/files/directory_scanner.py | 15 +- marimo/_server/files/os_file_system.py | 3 - marimo/_session/notebook/serializer.py | 36 ++++ tests/_session/notebook/test_serializer.py | 229 +++++++++++++++++++++ 6 files changed, 286 insertions(+), 6 deletions(-) diff --git a/marimo/_convert/ipynb/to_ir.py b/marimo/_convert/ipynb/to_ir.py index f25771bbd39..6e96f12b5c7 100644 --- a/marimo/_convert/ipynb/to_ir.py +++ b/marimo/_convert/ipynb/to_ir.py @@ -1527,9 +1527,14 @@ def _run_transform( def convert_from_ipynb_to_notebook_ir( raw_notebook: str, + filepath: str | None = None, ) -> NotebookSerializationV1: """ Convert a raw notebook to a NotebookSerializationV1 object. + + Args: + raw_notebook: JSON string of the notebook + filepath: Optional filepath for the notebook (used for error reporting) """ notebook = json.loads(raw_notebook) @@ -1589,4 +1594,5 @@ def convert_from_ipynb_to_notebook_ir( ) for cell in transformed_cells ], + filename=filepath, ) diff --git a/marimo/_server/api/endpoints/editing.py b/marimo/_server/api/endpoints/editing.py index e07e7d3cc4c..ac92c82700b 100644 --- a/marimo/_server/api/endpoints/editing.py +++ b/marimo/_server/api/endpoints/editing.py @@ -141,7 +141,8 @@ async def format_cell(request: Request) -> FormatResponse: body = await parse_request(request, cls=FormatCellsRequest) formatter = DefaultFormatter(line_length=body.line_length) filename = app_state.require_current_session().app_file_manager.path - if filename and filename.endswith((".md", ".qmd")): + # For non-Python formats (markdown, ipynb, etc.), format code as Python + if filename and not filename.endswith(".py"): filename = f"{filename}.py" try: diff --git a/marimo/_server/files/directory_scanner.py b/marimo/_server/files/directory_scanner.py index 447cc05d6d1..1ce713005c8 100644 --- a/marimo/_server/files/directory_scanner.py +++ b/marimo/_server/files/directory_scanner.py @@ -142,9 +142,20 @@ def __init__( @property def allowed_extensions(self) -> tuple[str, ...]: """Get allowed file extensions based on settings.""" + from marimo._session.notebook.serializer import ( + DEFAULT_NOTEBOOK_SERIALIZERS, + ) + + # Get all supported extensions from the serializer registry + all_exts = list(DEFAULT_NOTEBOOK_SERIALIZERS.keys()) + + # Filter based on include_markdown setting + # Markdown files are .md and .qmd if self.include_markdown: - return (".py", ".md", ".qmd") - return (".py",) + return tuple(sorted(all_exts)) + else: + # Only include Python and ipynb formats, not markdown + return tuple(e for e in all_exts if e not in (".md", ".qmd")) def scan(self) -> list[FileInfo]: """Scan directory and return file tree. diff --git a/marimo/_server/files/os_file_system.py b/marimo/_server/files/os_file_system.py index 8d121d375cd..f37a384a374 100644 --- a/marimo/_server/files/os_file_system.py +++ b/marimo/_server/files/os_file_system.py @@ -146,9 +146,6 @@ def get_details( ) def _is_marimo_file(self, path: str) -> bool: - file_path = Path(path) - if file_path.suffix not in (".py", ".md", ".qmd"): - return False from marimo._server.files.directory_scanner import is_marimo_app diff --git a/marimo/_session/notebook/serializer.py b/marimo/_session/notebook/serializer.py index 6db15c964c7..407be63f24c 100644 --- a/marimo/_session/notebook/serializer.py +++ b/marimo/_session/notebook/serializer.py @@ -119,11 +119,47 @@ def extract_header(self, path: Path) -> str | None: return yaml.dump(frontmatter, sort_keys=False) +class IpynbNotebookSerializer(NotebookSerializer): + """Handler for Jupyter Notebook (.ipynb) files.""" + + def serialize(self, notebook: NotebookSerializationV1) -> str: + """Serialize notebook to Jupyter ipynb format. + + NOTE: This is currently a stub. ``convert_from_ir_to_ipynb()`` + requires an ``InternalApp`` object but the serializer protocol only + provides ``NotebookSerializationV1`` (the IR). The conversion code + needs to be refactored into a two-phase process (see Phase 1.1 plan). + """ + raise NotImplementedError( + "IpynbNotebookSerializer.serialize is not yet implemented. " + "See PLAN.md: Phase 1.1 — IR to ipynb conversion refactoring." + ) + + def deserialize( + self, content: str, filepath: str | None = None + ) -> NotebookSerializationV1: + """Deserialize Jupyter ipynb notebook content to IR.""" + from marimo._convert.ipynb.to_ir import ( + convert_from_ipynb_to_notebook_ir, + ) + + return convert_from_ipynb_to_notebook_ir(content, filepath=filepath) + + def extract_header(self, path: Path) -> str | None: + """Extract header/metadata from ipynb file. + + For now, returns None as ipynb metadata is handled differently. + The metadata is preserved through the serialize/deserialize cycle. + """ + return None + + # Default format handlers DEFAULT_NOTEBOOK_SERIALIZERS = { ".py": PythonNotebookSerializer(), ".md": MarkdownNotebookSerializer(), ".qmd": MarkdownNotebookSerializer(), + ".ipynb": IpynbNotebookSerializer(), } diff --git a/tests/_session/notebook/test_serializer.py b/tests/_session/notebook/test_serializer.py index 519dcaa451e..9ca2fc15c86 100644 --- a/tests/_session/notebook/test_serializer.py +++ b/tests/_session/notebook/test_serializer.py @@ -7,6 +7,7 @@ import pytest +from marimo._dependencies.dependencies import DependencyManager from marimo._schemas.serialization import ( AppInstantiation, CellDef, @@ -15,6 +16,7 @@ ) from marimo._session.notebook.serializer import ( DEFAULT_NOTEBOOK_SERIALIZERS, + IpynbNotebookSerializer, MarkdownNotebookSerializer, PythonNotebookSerializer, get_notebook_serializer, @@ -270,6 +272,85 @@ def test_deserialize_with_frontmatter(self) -> None: assert result.app.options.get("app_title") == "Test Notebook" +class TestIpynbNotebookSerializer: + def test_deserialize_basic_ipynb(self) -> None: + """Test deserializing a basic ipynb notebook.""" + serializer = IpynbNotebookSerializer() + + # Create a simple ipynb structure + import json + + ipynb_content = json.dumps( + { + "cells": [ + { + "cell_type": "code", + "source": ["import marimo\n", "x = 1"], + "metadata": {}, + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3", + }, + "language_info": {"name": "python", "version": "3.11.0"}, + }, + "nbformat": 4, + "nbformat_minor": 5, + } + ) + + result = serializer.deserialize(ipynb_content) + + assert result is not None + assert len(result.cells) >= 1 + # Should contain the code we put in + assert any("import marimo" in cell.code for cell in result.cells) + + def test_deserialize_ipynb_with_filepath(self) -> None: + """Test that filepath is propagated through ipynb deserialization.""" + serializer = IpynbNotebookSerializer() + + import json + + ipynb_content = json.dumps( + { + "cells": [ + {"cell_type": "code", "source": ["x = 1"], "metadata": {}} + ], + "metadata": {}, + "nbformat": 4, + "nbformat_minor": 5, + } + ) + + filepath = "/path/to/notebook.ipynb" + result = serializer.deserialize(ipynb_content, filepath=filepath) + + assert result is not None + assert result.filename == filepath + + def test_extract_header_ipynb(self, tmp_path: Path) -> None: + """Test extract_header for ipynb files (returns None as metadata is handled differently).""" + serializer = IpynbNotebookSerializer() + test_file = tmp_path / "notebook.ipynb" + + import json + + ipynb_content = json.dumps( + {"cells": [], "metadata": {}, "nbformat": 4, "nbformat_minor": 5} + ) + + test_file.write_text(ipynb_content, encoding="utf-8") + + # extract_header should return None for ipynb as metadata is handled differently + header = serializer.extract_header(test_file) + + assert header is None + + class TestGetFormatHandler: def test_get_python_handler(self, tmp_path: Path) -> None: path = tmp_path / "notebook.py" @@ -292,6 +373,13 @@ def test_get_qmd_handler(self, tmp_path: Path) -> None: assert isinstance(handler, MarkdownNotebookSerializer) + def test_get_ipynb_handler(self, tmp_path: Path) -> None: + path = tmp_path / "notebook.ipynb" + + handler = get_notebook_serializer(path) + + assert isinstance(handler, IpynbNotebookSerializer) + def test_get_handler_with_string_path(self, tmp_path: Path) -> None: path_str = str(tmp_path / "notebook.py") @@ -312,6 +400,7 @@ def test_default_handlers_registered(self) -> None: assert ".py" in DEFAULT_NOTEBOOK_SERIALIZERS assert ".md" in DEFAULT_NOTEBOOK_SERIALIZERS assert ".qmd" in DEFAULT_NOTEBOOK_SERIALIZERS + assert ".ipynb" in DEFAULT_NOTEBOOK_SERIALIZERS class TestDeserializationWithFilenames: @@ -374,3 +463,143 @@ def test_qmd_notebook_filepath_propagation(self, tmp_path: Path) -> None: assert result.filename == str(filepath) assert isinstance(handler, MarkdownNotebookSerializer) + + def test_ipynb_notebook_filepath_propagation(self, tmp_path: Path) -> None: + """Test ipynb notebook filepath propagation.""" + import json + + filepath = tmp_path / "test.ipynb" + content = json.dumps( + { + "cells": [ + {"cell_type": "code", "source": ["x = 1"], "metadata": {}} + ], + "metadata": {}, + "nbformat": 4, + "nbformat_minor": 5, + } + ) + filepath.write_text(content, encoding="utf-8") + + handler = get_notebook_serializer(filepath) + result = handler.deserialize(content, filepath=str(filepath)) + + assert result.filename == str(filepath) + assert isinstance(handler, IpynbNotebookSerializer) + + +def _check_round_trip( + serializer: PythonNotebookSerializer + | MarkdownNotebookSerializer + | IpynbNotebookSerializer, + original: NotebookSerializationV1, + *, + expected_cells: int, + expected_fragments: list[str], + expected_header: str | None = None, +) -> None: + """Serialize *original*, deserialize the result, and verify fidelity.""" + serialized = serializer.serialize(original) + deserialized = serializer.deserialize(serialized) + + assert deserialized is not None + assert len(deserialized.cells) == expected_cells + for fragment in expected_fragments: + assert any(fragment in cell.code for cell in deserialized.cells), ( + f"Expected fragment {fragment!r} not found in any cell" + ) + if expected_header is not None: + assert deserialized.header is not None + assert expected_header in deserialized.header.value + + +_PYTHON_BASIC = NotebookSerializationV1( + app=AppInstantiation(), + cells=[ + CellDef(name="cell1", code="x = 1"), + CellDef(name="cell2", code="y = x + 1"), + ], +) +_PYTHON_WITH_HEADER = NotebookSerializationV1( + app=AppInstantiation(), + header=Header(value="# Test header\n# More info"), + cells=[CellDef(name="cell1", code="x = 1")], +) +_MARKDOWN = NotebookSerializationV1( + app=AppInstantiation(), + cells=[ + CellDef(name="md_cell", code="# Title"), + CellDef(name="code_cell", code="x = 1"), + ], +) +_IPYNB = NotebookSerializationV1( + app=AppInstantiation(), + cells=[ + CellDef(name="cell1", code="import marimo"), + CellDef(name="cell2", code="x = 42"), + ], +) + + +class TestRoundTripSerialization: + """Parameterized round-trip (serialize → deserialize) for all format handlers.""" + + @pytest.mark.parametrize( + "serializer, original, expected_cells, fragments, header", + [ + pytest.param( + PythonNotebookSerializer(), + _PYTHON_BASIC, + 2, + ["x = 1", "y = x + 1"], + None, + id="python_basic", + ), + pytest.param( + PythonNotebookSerializer(), + _PYTHON_WITH_HEADER, + 1, + ["x = 1"], + "# Test header", + id="python_with_header", + ), + pytest.param( + MarkdownNotebookSerializer(), + _MARKDOWN, + 2, + ["# Title", "x = 1"], + None, + id="markdown", + ), + pytest.param( + IpynbNotebookSerializer(), + _IPYNB, + 2, + ["x = 42"], + None, + id="ipynb", + marks=pytest.mark.xfail( + strict=True, + reason="serialize() is a stub — " + "PLAN.md Phase 1.1 refactoring needed", + ), + ), + ], + ) + def test_format_round_trip( + self, + serializer: PythonNotebookSerializer + | MarkdownNotebookSerializer + | IpynbNotebookSerializer, + original: NotebookSerializationV1, + expected_cells: int, + fragments: list[str], + header: str | None, + ) -> None: + _check_round_trip( + serializer, + original, + expected_cells=expected_cells, + expected_fragments=fragments, + expected_header=header, + ) From e8216a5e1b0fddb77265530b123ec5a8068b295d Mon Sep 17 00:00:00 2001 From: Alex Ford Date: Mon, 15 Jun 2026 17:35:56 -0700 Subject: [PATCH 2/5] Add ir-to-ipynb conversion for serializer. --- marimo/_convert/ipynb/from_ir.py | 84 ++++++++++++++++++++++ marimo/_session/notebook/serializer.py | 13 +--- pyproject.toml | 2 + tests/_session/notebook/test_serializer.py | 5 -- 4 files changed, 89 insertions(+), 15 deletions(-) diff --git a/marimo/_convert/ipynb/from_ir.py b/marimo/_convert/ipynb/from_ir.py index cc35b5f9e65..f469fd5606a 100644 --- a/marimo/_convert/ipynb/from_ir.py +++ b/marimo/_convert/ipynb/from_ir.py @@ -27,6 +27,7 @@ from nbformat.notebooknode import NotebookNode # type: ignore from marimo._ast.app import InternalApp + from marimo._schemas.serialization import NotebookSerializationV1 from marimo._session.state.session_view import SessionView @@ -57,6 +58,89 @@ def _extract_markdown_prefix(code: str) -> str: } +def ir_to_ipynb( + ir: NotebookSerializationV1, + *, + session_view: SessionView | None = None, +) -> str: + """Convert a ``NotebookSerializationV1`` (the IR) directly to ipynb. + + Unlike ``convert_from_ir_to_ipynb`` this does **not** require a full + ``InternalApp`` object, making it suitable for the serializer interface + which only has access to the IR. + + Args: + ir: Notebook intermediate representation. + session_view: Optional session view to include cell outputs. + + Returns: + JSON string of the .ipynb notebook. + """ + from marimo._ast.compiler import ir_cell_factory + from marimo._types.ids import CellId_t + + DependencyManager.nbformat.require("to convert marimo notebooks to ipynb") + import nbformat # type: ignore[import-not-found] + + from marimo import __version__ + + notebook = nbformat.v4.new_notebook() # type: ignore[no-untyped-call] + notebook["cells"] = [] + + # Add marimo-specific notebook metadata + marimo_metadata: dict[str, Any] = { + "marimo_version": __version__, + } + if ir.app.options: + marimo_metadata["app_config"] = ir.app.options + if ir.header and ir.header.value: + marimo_metadata["header"] = ir.header.value + notebook["metadata"]["marimo"] = marimo_metadata + + # Add standard Jupyter language_info (no kernelspec) + notebook["metadata"]["language_info"] = DEFAULT_LANGUAGE_INFO + + # Build cells in document order (top-down) + for i, cell_def in enumerate(ir.cells): + cell_id = CellId_t(f"auto_{i}") + + # Compile the cell so we can detect markdown cells + try: + cell = ir_cell_factory(cell_def, cell_id) + except SyntaxError: + cell = None + + cell_config = CellConfig.from_dict(cell_def.options) + + # Get outputs if session_view is provided + outputs: list[NotebookNode] = [] + if session_view is not None: + cell_output = session_view.get_cell_outputs([cell_id]).get( + cell_id, None + ) + cell_console_outputs = session_view.get_cell_console_outputs( + [cell_id] + ).get(cell_id, []) + outputs = _convert_marimo_output_to_ipynb( + cell_output, cell_console_outputs + ) + + notebook_cell = _create_ipynb_cell( + cell_id=cell_id, + code=cell_def.code, + name=cell_def.name, + config=cell_config, + cell=cell, + outputs=outputs, + ) + notebook["cells"].append(notebook_cell) + + stream = io.StringIO() + nbformat.write(notebook, stream) # type: ignore[no-untyped-call] + stream.seek(0) + return stream.read() + + def convert_from_ir_to_ipynb( app: InternalApp, *, diff --git a/marimo/_session/notebook/serializer.py b/marimo/_session/notebook/serializer.py index 407be63f24c..3cf9af34976 100644 --- a/marimo/_session/notebook/serializer.py +++ b/marimo/_session/notebook/serializer.py @@ -123,17 +123,10 @@ class IpynbNotebookSerializer(NotebookSerializer): """Handler for Jupyter Notebook (.ipynb) files.""" def serialize(self, notebook: NotebookSerializationV1) -> str: - """Serialize notebook to Jupyter ipynb format. + """Serialize notebook to Jupyter ipynb format.""" + from marimo._convert.ipynb.from_ir import ir_to_ipynb - NOTE: This is currently a stub. ``convert_from_ir_to_ipynb()`` - requires an ``InternalApp`` object but the serializer protocol only - provides ``NotebookSerializationV1`` (the IR). The conversion code - needs to be refactored into a two-phase process (see Phase 1.1 plan). - """ - raise NotImplementedError( - "IpynbNotebookSerializer.serialize is not yet implemented. " - "See PLAN.md: Phase 1.1 — IR to ipynb conversion refactoring." - ) + return ir_to_ipynb(notebook, session_view=None) def deserialize( self, content: str, filepath: str | None = None diff --git a/pyproject.toml b/pyproject.toml index fa90f603ac2..31a5fc15caf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -167,6 +167,8 @@ test = [ "pillow>=9", "zstandard>=0.21.0", "cffi>=1.16.0", + # ipynb serializer round-trip tests + "nbformat>=5.10.4", ] test-optional = [ diff --git a/tests/_session/notebook/test_serializer.py b/tests/_session/notebook/test_serializer.py index 9ca2fc15c86..73dce9aab53 100644 --- a/tests/_session/notebook/test_serializer.py +++ b/tests/_session/notebook/test_serializer.py @@ -578,11 +578,6 @@ class TestRoundTripSerialization: ["x = 42"], None, id="ipynb", - marks=pytest.mark.xfail( - strict=True, - reason="serialize() is a stub — " - "PLAN.md Phase 1.1 refactoring needed", - ), ), ], ) From 2e4be882165f417a507a104116391dfd282801b6 Mon Sep 17 00:00:00 2001 From: Alex Ford Date: Mon, 15 Jun 2026 18:06:58 -0700 Subject: [PATCH 3/5] Refactor directory scanner to move format-detection logic into format serializers. --- marimo/_server/files/directory_scanner.py | 58 +++------- marimo/_session/notebook/serializer.py | 76 +++++++++++++ tests/_server/test_directory_scanner.py | 98 ++++++++++++++++ tests/_session/notebook/test_serializer.py | 126 +++++++++++++++++++++ 4 files changed, 317 insertions(+), 41 deletions(-) diff --git a/marimo/_server/files/directory_scanner.py b/marimo/_server/files/directory_scanner.py index 1ce713005c8..3bf1edf9e7d 100644 --- a/marimo/_server/files/directory_scanner.py +++ b/marimo/_server/files/directory_scanner.py @@ -9,7 +9,6 @@ from marimo._server.files.os_file_system import natural_sort_file from marimo._server.models.files import FileInfo from marimo._utils.http import HTTPException, HTTPStatus -from marimo._utils.marimo_path import MarimoPath LOGGER = _loggers.marimo_logger() @@ -18,50 +17,27 @@ def is_marimo_app(full_path: str) -> bool: """ Detect whether a file is a marimo app. - Rules: - - Markdown (`.md`/`.qmd`) files are marimo apps if they contain - `marimo-version:` (frontmatter marker). - - Python (`.py`) files are marimo apps if they contain both - `marimo.App` and `import marimo`. - - In both cases the first 512 bytes are scanned first (fast path); - on a miss we read up to 1 MB of the file looking for the markers. - Above `import marimo` there's only ever a shebang, comments, a - module docstring, and/or a `# /// script` block — none of which - realistically exceed a few hundred KB. - - Any errors while reading result in `False`. + Delegates to the appropriate ``NotebookSerializer`` based on the file + extension. Each serializer implements format-specific detection: + + - Python (``.py``) — checks for ``import marimo`` + ``marimo.App`` + - Markdown (``.md``/``.qmd``) — checks for ``marimo-version:`` frontmatter + - Jupyter (``.ipynb``) — checks for ``metadata.marimo`` in the JSON + + Falls back to ``False`` for unknown extensions or I/O errors. """ - FAST_PATH_BYTES = 512 - # Cap on how far we'll read looking for markers. Marimo notebooks - # put `import marimo` near the top of the file, so this is just a - # guard against scanning huge unrelated Python files in full. - MAX_SCAN_BYTES = 1 * 1024 * 1024 # 1 MB + from marimo._session.notebook.serializer import ( + get_notebook_serializer, + ) + path_obj = Path(full_path) try: - path = MarimoPath(full_path) + serializer = get_notebook_serializer(path_obj) + except ValueError: + return False - # Fast extension check to avoid I/O for unrelated files. - if path.is_markdown(): - markers: tuple[bytes, ...] = (b"marimo-version:",) - elif path.is_python(): - markers = (b"import marimo", b"marimo.App") - else: - return False - - def matches(content: bytes) -> bool: - return all(m in content for m in markers) - - with open(full_path, "rb") as f: - header = f.read(FAST_PATH_BYTES) - if matches(header): - return True - # Fast path missed. If the file is smaller than the window, - # we've already seen everything. - if len(header) < FAST_PATH_BYTES: - return False - # Read further, bounded by MAX_SCAN_BYTES. If markers are - # past that, the file isn't shaped like a marimo notebook. - rest = f.read(MAX_SCAN_BYTES - FAST_PATH_BYTES) - return matches(header + rest) + try: + return serializer.is_marimo_notebook(path_obj) except Exception as e: LOGGER.debug("Error reading file %s: %s", full_path, e) return False diff --git a/marimo/_session/notebook/serializer.py b/marimo/_session/notebook/serializer.py index 3cf9af34976..75e346bf0ce 100644 --- a/marimo/_session/notebook/serializer.py +++ b/marimo/_session/notebook/serializer.py @@ -53,6 +53,17 @@ def extract_header(self, path: Path) -> str | None: """ ... + def is_marimo_notebook(self, path: Path) -> bool: + """Check if a file is a marimo notebook. + + Args: + path: File path to check + + Returns: + True if the file is a marimo notebook, False otherwise + """ + ... + class PythonNotebookSerializer(NotebookSerializer): """Handler for Python (.py) notebook files.""" @@ -83,6 +94,32 @@ def extract_header(self, path: Path) -> str | None: return get_header_comments(path) + def is_marimo_notebook(self, path: Path) -> bool: + """Check if a Python file is a marimo notebook. + + Scans the file for ``import marimo`` and ``marimo.App`` markers. + First reads 512 bytes (fast path), and on a miss reads up to 1 MB. + """ + FAST_PATH_BYTES = 512 + MAX_SCAN_BYTES = 1 * 1024 * 1024 # 1 MB + + markers: tuple[bytes, ...] = (b"import marimo", b"marimo.App") + + def matches(content: bytes) -> bool: + return all(m in content for m in markers) + + try: + with open(path, "rb") as f: + header = f.read(FAST_PATH_BYTES) + if matches(header): + return True + if len(header) < FAST_PATH_BYTES: + return False + rest = f.read(MAX_SCAN_BYTES - FAST_PATH_BYTES) + return matches(header + rest) + except Exception: + return False + class MarkdownNotebookSerializer(NotebookSerializer): """Handler for Markdown (.md) notebook files.""" @@ -118,6 +155,29 @@ def extract_header(self, path: Path) -> str | None: return None return yaml.dump(frontmatter, sort_keys=False) + def is_marimo_notebook(self, path: Path) -> bool: + """Check if a Markdown file is a marimo notebook. + + Scans the file for the ``marimo-version:`` frontmatter marker. + First reads 512 bytes (fast path), and on a miss reads up to 1 MB. + """ + FAST_PATH_BYTES = 512 + MAX_SCAN_BYTES = 1 * 1024 * 1024 # 1 MB + + marker: bytes = b"marimo-version:" + + try: + with open(path, "rb") as f: + header = f.read(FAST_PATH_BYTES) + if marker in header: + return True + if len(header) < FAST_PATH_BYTES: + return False + rest = f.read(MAX_SCAN_BYTES - FAST_PATH_BYTES) + return marker in (header + rest) + except Exception: + return False + class IpynbNotebookSerializer(NotebookSerializer): """Handler for Jupyter Notebook (.ipynb) files.""" @@ -146,6 +206,22 @@ def extract_header(self, path: Path) -> str | None: """ return None + def is_marimo_notebook(self, path: Path) -> bool: + """Check if an ipynb file is a marimo notebook. + + Checks for the presence of ``metadata.marimo`` key in the notebook + JSON structure — marimo-generated ipynb files include this metadata. + """ + import json + + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + metadata = data.get("metadata", {}) + return "marimo" in metadata + except Exception: + return False + # Default format handlers DEFAULT_NOTEBOOK_SERIALIZERS = { diff --git a/tests/_server/test_directory_scanner.py b/tests/_server/test_directory_scanner.py index c761d3f47cf..593ad523588 100644 --- a/tests/_server/test_directory_scanner.py +++ b/tests/_server/test_directory_scanner.py @@ -189,3 +189,101 @@ def test_partial_results_populated_during_scan(self, test_dir: Path): for f in scanner.partial_results: assert not f.is_directory assert f.is_marimo_file + + +# --- ipynb detection and discovery tests --- + +MARIMO_IPYNB = """\ +{ + "cells": [], + "metadata": {"marimo": {"marimo_version": "0.1.0"}}, + "nbformat": 4, + "nbformat_minor": 5 +}""" + +PLAIN_IPYNB = """\ +{ + "cells": [{"cell_type": "code","source": ["x=1"],"metadata": {}}], + "metadata": {"kernelspec": {"display_name":"Python 3","language":"python","name":"python3"}}, + "nbformat": 4, + "nbformat_minor": 5 +}""" + + +def test_ipynb_marimo_app_detected(tmp_path: Path): + f = _write(tmp_path / "notebook.ipynb", MARIMO_IPYNB) + assert is_marimo_app(str(f)) is True + + +def test_ipynb_non_marimo_app(tmp_path: Path): + f = _write(tmp_path / "plain.ipynb", PLAIN_IPYNB) + assert is_marimo_app(str(f)) is False + + +def test_ipynb_app_detected_os_filesystem(tmp_path: Path): + """os_file_system._is_marimo_file also delegates to is_marimo_app.""" + from marimo._server.files.os_file_system import OSFileSystem + + fs = OSFileSystem() + f = _write(tmp_path / "marimo.ipynb", MARIMO_IPYNB) + info = fs._get_file_info(str(f)) + assert info.is_marimo_file is True + + +def test_ipynb_non_app_os_filesystem(tmp_path: Path): + """Non-marimo ipynb files are not flagged by os_file_system.""" + from marimo._server.files.os_file_system import OSFileSystem + + fs = OSFileSystem() + f = _write(tmp_path / "plain.ipynb", PLAIN_IPYNB) + info = fs._get_file_info(str(f)) + assert info.is_marimo_file is False + + +@pytest.fixture +def test_dir_with_ipynb(tmp_path: Path) -> Path: + """Fixture dir with python apps, markdown notebook, and ipynb notebooks.""" + _write(tmp_path / "app1.py", MARIMO_APP) + _write(tmp_path / "app2.py", MARIMO_APP) + _write(tmp_path / "notebook.md", MARIMO_MD) + _write(tmp_path / "marimo.ipynb", MARIMO_IPYNB) + _write(tmp_path / "plain.ipynb", PLAIN_IPYNB) # should NOT be discovered + nested = tmp_path / "nested" + nested.mkdir() + _write(nested / "nested_app.py", MARIMO_APP) + return tmp_path + + +class TestDirectoryScannerIpynb: + def test_ipynb_discovered_in_default_scan(self, test_dir_with_ipynb: Path): + """ipynb files are found by default (without include_markdown).""" + files = DirectoryScanner(str(test_dir_with_ipynb)).scan() + names = _file_names(files) + assert "marimo.ipynb" in names + assert "app1.py" in names + assert "app2.py" in names + # Plain ipynb (no marimo metadata) should NOT be discovered + assert "plain.ipynb" not in names + + def test_ipynb_not_in_markdown_mode(self, test_dir_with_ipynb: Path): + """include_markdown=True should not exclude ipynb files.""" + files = DirectoryScanner( + str(test_dir_with_ipynb), include_markdown=True + ).scan() + names = _file_names(files) + assert "marimo.ipynb" in names + assert "notebook.md" in names + assert "app1.py" in names + assert "app2.py" in names + + def test_ipynb_in_nested_directory(self, test_dir_with_ipynb: Path): + """Nested ipynb files should be discovered.""" + nested_ipynb = test_dir_with_ipynb / "nested" / "deep.ipynb" + _write(nested_ipynb, MARIMO_IPYNB) + files = DirectoryScanner(str(test_dir_with_ipynb)).scan() + nested = next( + f for f in files if f.is_directory and f.name == "nested" + ) + assert nested.children is not None + names = [c.name for c in nested.children] + assert "deep.ipynb" in names diff --git a/tests/_session/notebook/test_serializer.py b/tests/_session/notebook/test_serializer.py index 73dce9aab53..835acbd2a41 100644 --- a/tests/_session/notebook/test_serializer.py +++ b/tests/_session/notebook/test_serializer.py @@ -488,6 +488,132 @@ def test_ipynb_notebook_filepath_propagation(self, tmp_path: Path) -> None: assert isinstance(handler, IpynbNotebookSerializer) +class TestIsMarimoNotebook: + """Tests for the ``is_marimo_notebook`` detection method on each serializer.""" + + # --- PythonNotebookSerializer --- + + def test_python_marimo_detected(self, tmp_path: Path) -> None: + serializer = PythonNotebookSerializer() + f = tmp_path / "app.py" + f.write_text("import marimo\napp = marimo.App()\n") + assert serializer.is_marimo_notebook(f) is True + + def test_python_non_marimo(self, tmp_path: Path) -> None: + serializer = PythonNotebookSerializer() + f = tmp_path / "other.py" + f.write_text("import sys\nprint('hi')\n") + assert serializer.is_marimo_notebook(f) is False + + def test_python_marimo_with_long_docstring(self, tmp_path: Path) -> None: + """Long docstring must not hide markers (slow-path test).""" + serializer = PythonNotebookSerializer() + f = tmp_path / "app.py" + f.write_text('"""' + ("x" * 1024) + '"""\n' + "import marimo\napp = marimo.App()\n") + assert serializer.is_marimo_notebook(f) is True + + def test_python_non_marimo_long(self, tmp_path: Path) -> None: + """Slow-path scan still rejects non-marimo Python files.""" + serializer = PythonNotebookSerializer() + f = tmp_path / "other.py" + f.write_text('"""' + ("x" * 1024) + '"""\nimport sys\nprint("hi")\n') + assert serializer.is_marimo_notebook(f) is False + + def test_python_missing_file(self, tmp_path: Path) -> None: + serializer = PythonNotebookSerializer() + f = tmp_path / "nonexistent.py" + assert serializer.is_marimo_notebook(f) is False + + # --- MarkdownNotebookSerializer --- + + def test_markdown_marimo_detected(self, tmp_path: Path) -> None: + serializer = MarkdownNotebookSerializer() + f = tmp_path / "notebook.md" + f.write_text("---\nmarimo-version: 0.1.0\n---\n") + assert serializer.is_marimo_notebook(f) is True + + def test_markdown_non_marimo(self, tmp_path: Path) -> None: + serializer = MarkdownNotebookSerializer() + f = tmp_path / "plain.md" + f.write_text("# Just markdown\n") + assert serializer.is_marimo_notebook(f) is False + + def test_markdown_marimo_long_frontmatter(self, tmp_path: Path) -> None: + """Long YAML frontmatter must not hide marimo-version marker.""" + serializer = MarkdownNotebookSerializer() + padding = "\n".join(f"key{i}: value{i}" for i in range(50)) + content = f"---\n{padding}\nmarimo-version: 0.1.0\n---\n" + assert len(content.encode()) > 512 # exercise the slow path + f = tmp_path / "notebook.md" + f.write_text(content) + assert serializer.is_marimo_notebook(f) is True + + def test_markdown_missing_file(self, tmp_path: Path) -> None: + serializer = MarkdownNotebookSerializer() + f = tmp_path / "nonexistent.md" + assert serializer.is_marimo_notebook(f) is False + + # --- IpynbNotebookSerializer --- + + def test_ipynb_marimo_detected(self, tmp_path: Path) -> None: + """Ipynb with marimo metadata is detected.""" + import json + + serializer = IpynbNotebookSerializer() + f = tmp_path / "notebook.ipynb" + data = { + "cells": [], + "metadata": {"marimo": {"marimo_version": "0.1.0"}}, + "nbformat": 4, + "nbformat_minor": 5, + } + f.write_text(json.dumps(data)) + assert serializer.is_marimo_notebook(f) is True + + def test_ipynb_non_marimo(self, tmp_path: Path) -> None: + """Standard Jupyter ipynb without marimo metadata is not detected.""" + import json + + serializer = IpynbNotebookSerializer() + f = tmp_path / "plain.ipynb" + data = { + "cells": [], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3", + } + }, + "nbformat": 4, + "nbformat_minor": 5, + } + f.write_text(json.dumps(data)) + assert serializer.is_marimo_notebook(f) is False + + def test_ipynb_empty_metadata(self, tmp_path: Path) -> None: + """Ipynb with empty metadata dict is not a marimo notebook.""" + import json + + serializer = IpynbNotebookSerializer() + f = tmp_path / "empty.ipynb" + data = {"cells": [], "metadata": {}, "nbformat": 4, "nbformat_minor": 5} + f.write_text(json.dumps(data)) + assert serializer.is_marimo_notebook(f) is False + + def test_ipynb_invalid_json(self, tmp_path: Path) -> None: + """Invalid JSON is handled gracefully.""" + serializer = IpynbNotebookSerializer() + f = tmp_path / "bad.ipynb" + f.write_text("not valid json") + assert serializer.is_marimo_notebook(f) is False + + def test_ipynb_missing_file(self, tmp_path: Path) -> None: + serializer = IpynbNotebookSerializer() + f = tmp_path / "nonexistent.ipynb" + assert serializer.is_marimo_notebook(f) is False + + def _check_round_trip( serializer: PythonNotebookSerializer | MarkdownNotebookSerializer From 1dd254177a4ddbc7d0a795231709bb3a4f68253f Mon Sep 17 00:00:00 2001 From: Alex Ford Date: Mon, 15 Jun 2026 18:59:47 -0700 Subject: [PATCH 4/5] Update hardcoded extension collections with .ipynb, test .py to .ipynb via rename --- .../editor/header/filename-input.tsx | 8 +-- frontend/src/components/pages/home-page.tsx | 2 + marimo/_cli/files/file_path.py | 47 ++++++++++++----- marimo/_server/asgi.py | 4 ++ marimo/_server/files/os_file_system.py | 6 +++ marimo/_utils/marimo_path.py | 5 +- tests/_cli/test_file_path.py | 47 +++++++++++++++-- .../notebook/test_app_file_manager.py | 51 +++++++++++++++++++ 8 files changed, 150 insertions(+), 20 deletions(-) diff --git a/frontend/src/components/editor/header/filename-input.tsx b/frontend/src/components/editor/header/filename-input.tsx index 032855d3f61..f0e94b9d204 100644 --- a/frontend/src/components/editor/header/filename-input.tsx +++ b/frontend/src/components/editor/header/filename-input.tsx @@ -218,10 +218,12 @@ function getSuggestion( return; } - // Matches allowed files in marimo/_utils/marimo_path.py + // NOTE: If new notebook formats are added to DEFAULT_NOTEBOOK_SERIALIZERS, + // add their extension here too. This list must stay in sync with the + // server-side serializer registry in marimo/_session/notebook/serializer.py. const extensionsToLeave = getFeatureFlag("markdown") - ? new Set(["py", "md", "markdown", "qmd"]) - : new Set(["py"]); + ? new Set(["py", "md", "markdown", "qmd", "ipynb"]) + : new Set(["py", "ipynb"]); if (extensionsToLeave.has(Paths.extension(search))) { // If ends with an allowed extension, leave as is diff --git a/frontend/src/components/pages/home-page.tsx b/frontend/src/components/pages/home-page.tsx index 763db0813ed..50c1b69cb76 100644 --- a/frontend/src/components/pages/home-page.tsx +++ b/frontend/src/components/pages/home-page.tsx @@ -378,6 +378,8 @@ const Node = ({ node, style }: NodeRendererProps) => { ? Paths.rest(node.data.path, root) : node.data.path; + // TODO: When .ipynb support is added to the home page, determine + // if it should show a different icon/badge (or the markdown icon). const isMarkdown = relativePath.endsWith(".md") || relativePath.endsWith(".qmd"); const isRunning = runningNotebooks.has(relativePath); diff --git a/marimo/_cli/files/file_path.py b/marimo/_cli/files/file_path.py index 3d03b28e353..a115de5b7c7 100644 --- a/marimo/_cli/files/file_path.py +++ b/marimo/_cli/files/file_path.py @@ -53,7 +53,7 @@ def get_gist_src_url(url: str) -> str: # by getting the raw_url from api.github.com path_parts = urllib.parse.urlparse(url).path.strip("/").split("/") if "raw" in path_parts: - if not path_parts[-1].endswith((".py", ".md")): + if not path_parts[-1].endswith((".py", ".md", ".ipynb")): raise ValueError("No python or markdown files found in the Gist") return url else: @@ -67,12 +67,12 @@ def get_gist_src_url(url: str) -> str: if not files_dict: raise ValueError("No files found in the Gist") - py_or_md_url_generator = ( + py_md_ipynb_url_generator = ( file_info["raw_url"] for filename, file_info in files_dict.items() - if filename.lower().endswith((".py", ".md")) + if filename.lower().endswith((".py", ".md", ".ipynb")) ) - raw_url = next(py_or_md_url_generator, "") + raw_url = next(py_md_ipynb_url_generator, "") if raw_url == "": raise ValueError("No python or markdown files found in the Gist") @@ -217,7 +217,11 @@ def _extract_filename_from_static_notebook(file_contents: str) -> str: class GitHubSourceReader(FileReader): def can_read(self, name: str) -> bool: - return is_github_src(name, ext=".py") or is_github_src(name, ext=".md") + return ( + is_github_src(name, ext=".py") + or is_github_src(name, ext=".md") + or is_github_src(name, ext=".ipynb") + ) def read(self, name: str) -> tuple[str, str]: url = get_github_src_url(name) @@ -313,15 +317,32 @@ def handle( return name, None if path.suffix == ".ipynb": - prefix = str(path)[: -len(".ipynb")] - raise click.ClickException( - f"Invalid NAME - {name} is not a Python file.\n\n" - f" {green('Tip:')} Convert {name} to a marimo notebook with" - "\n\n" - f" marimo convert {name} -o {prefix}.py\n\n" - f" then open with marimo edit {prefix}.py" + if not path.exists(): + if self.allow_new_file: + return name, None + raise click.ClickException( + f"Invalid NAME - {name} does not exist" + ) + # Verify the ipynb can be loaded by the registered serializer + from marimo._session.notebook.serializer import ( + IpynbNotebookSerializer, ) + try: + IpynbNotebookSerializer().deserialize( + path.read_text(encoding="utf-8") + ) + except Exception: + prefix = str(path)[: -len(".ipynb")] + raise click.ClickException( + f"Invalid NAME - {name} is not a valid Jupyter notebook.\n\n" + f" {green('Tip:')} Convert {name} to a marimo notebook with" + "\n\n" + f" marimo convert {name} -o {prefix}.py\n\n" + f" then open with marimo edit {prefix}.py" + ) + return name, None + if path.suffix == ".html": reader = StaticNotebookReader() if reader.can_read(name): @@ -391,7 +412,7 @@ def _create_tmp_file_from_content( LOGGER.info("Creating temporary file") path_to_app = Path(temp_dir.name) / name # If doesn't end in .py, add it - if path_to_app.suffix not in (".py", ".md", ".qmd"): + if path_to_app.suffix not in (".py", ".md", ".qmd", ".ipynb"): if "__generated_with" in content: path_to_app = path_to_app.with_suffix(".py") elif "marimo-version" in content: diff --git a/marimo/_server/asgi.py b/marimo/_server/asgi.py index 1eac5d91882..18893a37b9f 100644 --- a/marimo/_server/asgi.py +++ b/marimo/_server/asgi.py @@ -182,6 +182,8 @@ def _find_matching_file( # Try as a Python file potential_path = self.directory.joinpath(*prefix) + # TODO: When .ipynb notebook formats are cached, also try + # .ipynb suffix here (or check all registered serializers). cache_key = str(potential_path.with_suffix(".py")) if ( cache_key in self._app_cache @@ -306,6 +308,8 @@ async def __call__( relative_notebook = marimo_file.relative_to( self.directory ).as_posix() + # TODO: Handle .ipynb (and other registered serializer + # extensions) here when routing URLs for notebooks. if relative_notebook.endswith(".py"): relative_notebook = relative_notebook.removesuffix(".py") # Compute the URL prefix for this notebook. When diff --git a/marimo/_server/files/os_file_system.py b/marimo/_server/files/os_file_system.py index f37a384a374..8007a909e5c 100644 --- a/marimo/_server/files/os_file_system.py +++ b/marimo/_server/files/os_file_system.py @@ -199,6 +199,12 @@ def create_file_or_directory( converter = MarimoConvert.from_ir(ir) if full_path.suffix in (".md", ".qmd"): notebook_code = converter.to_markdown(full_path.name) + elif full_path.suffix == ".ipynb": + from marimo._session.notebook.serializer import ( + IpynbNotebookSerializer, + ) + + notebook_code = IpynbNotebookSerializer().serialize(ir) else: notebook_code = converter.to_py() full_path.write_text(notebook_code, encoding="utf-8") diff --git a/marimo/_utils/marimo_path.py b/marimo/_utils/marimo_path.py index 2da60df6353..824b5615a64 100644 --- a/marimo/_utils/marimo_path.py +++ b/marimo/_utils/marimo_path.py @@ -52,7 +52,7 @@ def is_valid_path(path: str | Path) -> bool: return False def is_valid(self) -> bool: - return self.is_python() or self.is_markdown() + return self.is_python() or self.is_markdown() or self.is_ipynb() def is_python(self) -> bool: return self.path.suffix == ".py" @@ -61,6 +61,9 @@ def is_markdown(self) -> bool: allowed = {".md", ".markdown", ".qmd"} return self.path.suffix in allowed + def is_ipynb(self) -> bool: + return self.path.suffix == ".ipynb" + def rename(self, new_path: Path) -> None: if self.strict: if not MarimoPath(new_path).is_relative_to(self.cwd): diff --git a/tests/_cli/test_file_path.py b/tests/_cli/test_file_path.py index e5ab64c22d5..5ac5bfa0315 100644 --- a/tests/_cli/test_file_path.py +++ b/tests/_cli/test_file_path.py @@ -247,12 +247,53 @@ def test_validate_name_with_markdown_file_remote() -> None: )[0].endswith("markdown_format.md") -def test_validate_name_with_jupyter_notebook(): +def test_validate_name_with_jupyter_notebook_not_found(): + """A non-existing .ipynb with allow_new_file=True should succeed (create new).""" + result_path, _ = validate_name( + "notebook.ipynb", allow_new_file=True, allow_directory=False + ) + assert result_path.endswith(".ipynb") + + +def test_validate_name_with_jupyter_notebook_not_found_strict(): + """A non-existing .ipynb with allow_new_file=False should raise.""" + with pytest.raises(click.ClickException) as excinfo: + validate_name( + "missing.ipynb", allow_new_file=False, allow_directory=False + ) + assert "does not exist" in str(excinfo.value) + + +def test_validate_name_with_invalid_ipynb(tmp_path: Path): + """An invalid .ipynb file should prompt the user to convert.""" + f = tmp_path / "bad.ipynb" + f.write_text("not valid json", encoding="utf-8") with pytest.raises(click.ClickException) as excinfo: validate_name( - "notebook.ipynb", allow_new_file=True, allow_directory=False + str(f), allow_new_file=True, allow_directory=False ) - assert "Convert notebook.ipynb to a marimo notebook" in str(excinfo.value) + assert "is not a valid Jupyter notebook" in str(excinfo.value) + assert "Convert" in str(excinfo.value) + + +def test_validate_name_with_valid_marimo_ipynb(tmp_path: Path): + """A valid marimo .ipynb should open successfully.""" + import json + + content = json.dumps( + { + "cells": [], + "metadata": {"marimo": {"marimo_version": "0.1.0"}}, + "nbformat": 4, + "nbformat_minor": 5, + } + ) + f = tmp_path / "notebook.ipynb" + f.write_text(content, encoding="utf-8") + result_path, _ = validate_name( + str(f), allow_new_file=True, allow_directory=False + ) + assert result_path == str(f) def test_generic_url_reader_with_query_params(): diff --git a/tests/_session/notebook/test_app_file_manager.py b/tests/_session/notebook/test_app_file_manager.py index d86323603a7..532041cf765 100644 --- a/tests/_session/notebook/test_app_file_manager.py +++ b/tests/_session/notebook/test_app_file_manager.py @@ -1,6 +1,7 @@ # Copyright 2026 Marimo. All rights reserved. from __future__ import annotations +import json import os from typing import TYPE_CHECKING @@ -88,3 +89,53 @@ def test_new_notebook_returns_unbacked_manager() -> None: def test_new_notebook_advances_document_version() -> None: fm = new_notebook() assert fm.app.cell_manager.document.version > 0 + + +def test_rename_changes_format_py_to_ipynb_and_back(tmp_path: Path) -> None: + """Renaming a .py file to .ipynb converts to ipynb format, + and renaming back to .py converts back to Python format.""" + # Create a .py notebook file + py_path = tmp_path / "test_notebook.py" + py_path.write_text(_NOTEBOOK_SOURCE) + + # Load the notebook + fm = load_notebook(py_path) + assert fm.filename == str(py_path.absolute()) + + # Rename .py → .ipynb + ipynb_path = tmp_path / "test_notebook.ipynb" + result = fm.rename(str(ipynb_path)) + assert result == ipynb_path.name + + # The old .py file should be gone (rename moves the file) + assert not py_path.exists() + # The new .ipynb file should exist + assert ipynb_path.exists() + + # Verify the .ipynb content is valid JSON with marimo metadata + with open(ipynb_path, "r", encoding="utf-8") as f: + ipynb_data = json.load(f) + assert "cells" in ipynb_data + assert "metadata" in ipynb_data + assert "marimo" in ipynb_data["metadata"] + assert "marimo_version" in ipynb_data["metadata"]["marimo"] + # Verify cell content is preserved + assert any("x = 1" in cell["source"] for cell in ipynb_data["cells"]) + + # Rename .ipynb → .py + new_py_path = tmp_path / "test_notebook.py" + result = fm.rename(str(new_py_path)) + assert result == new_py_path.name + + # The .ipynb file should be gone + assert not ipynb_path.exists() + # The .py file should exist again + assert new_py_path.exists() + + # Verify the .py content is valid marimo format + py_content = new_py_path.read_text(encoding="utf-8") + assert "import marimo" in py_content + assert "x = 1" in py_content + + # Verify the AppFileManager still points to the right file + assert fm.filename == str(new_py_path.absolute()) From bdc40c473fe5f1030c651405c1f0c6e9dfe81d1c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 03:37:41 +0000 Subject: [PATCH 5/5] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- marimo/_convert/ipynb/to_ir.py | 2 +- marimo/_session/notebook/serializer.py | 2 +- tests/_cli/test_file_path.py | 4 +--- tests/_session/notebook/test_app_file_manager.py | 2 +- tests/_session/notebook/test_serializer.py | 15 ++++++++++++--- 5 files changed, 16 insertions(+), 9 deletions(-) diff --git a/marimo/_convert/ipynb/to_ir.py b/marimo/_convert/ipynb/to_ir.py index 6e96f12b5c7..8b1ed674277 100644 --- a/marimo/_convert/ipynb/to_ir.py +++ b/marimo/_convert/ipynb/to_ir.py @@ -1531,7 +1531,7 @@ def convert_from_ipynb_to_notebook_ir( ) -> NotebookSerializationV1: """ Convert a raw notebook to a NotebookSerializationV1 object. - + Args: raw_notebook: JSON string of the notebook filepath: Optional filepath for the notebook (used for error reporting) diff --git a/marimo/_session/notebook/serializer.py b/marimo/_session/notebook/serializer.py index 75e346bf0ce..0a8b021dbd5 100644 --- a/marimo/_session/notebook/serializer.py +++ b/marimo/_session/notebook/serializer.py @@ -215,7 +215,7 @@ def is_marimo_notebook(self, path: Path) -> bool: import json try: - with open(path, "r", encoding="utf-8") as f: + with open(path, encoding="utf-8") as f: data = json.load(f) metadata = data.get("metadata", {}) return "marimo" in metadata diff --git a/tests/_cli/test_file_path.py b/tests/_cli/test_file_path.py index 5ac5bfa0315..ab7de3769fa 100644 --- a/tests/_cli/test_file_path.py +++ b/tests/_cli/test_file_path.py @@ -269,9 +269,7 @@ def test_validate_name_with_invalid_ipynb(tmp_path: Path): f = tmp_path / "bad.ipynb" f.write_text("not valid json", encoding="utf-8") with pytest.raises(click.ClickException) as excinfo: - validate_name( - str(f), allow_new_file=True, allow_directory=False - ) + validate_name(str(f), allow_new_file=True, allow_directory=False) assert "is not a valid Jupyter notebook" in str(excinfo.value) assert "Convert" in str(excinfo.value) diff --git a/tests/_session/notebook/test_app_file_manager.py b/tests/_session/notebook/test_app_file_manager.py index 532041cf765..0c1f3310f72 100644 --- a/tests/_session/notebook/test_app_file_manager.py +++ b/tests/_session/notebook/test_app_file_manager.py @@ -113,7 +113,7 @@ def test_rename_changes_format_py_to_ipynb_and_back(tmp_path: Path) -> None: assert ipynb_path.exists() # Verify the .ipynb content is valid JSON with marimo metadata - with open(ipynb_path, "r", encoding="utf-8") as f: + with open(ipynb_path, encoding="utf-8") as f: ipynb_data = json.load(f) assert "cells" in ipynb_data assert "metadata" in ipynb_data diff --git a/tests/_session/notebook/test_serializer.py b/tests/_session/notebook/test_serializer.py index 835acbd2a41..00c0af5f33c 100644 --- a/tests/_session/notebook/test_serializer.py +++ b/tests/_session/notebook/test_serializer.py @@ -7,7 +7,6 @@ import pytest -from marimo._dependencies.dependencies import DependencyManager from marimo._schemas.serialization import ( AppInstantiation, CellDef, @@ -509,7 +508,12 @@ def test_python_marimo_with_long_docstring(self, tmp_path: Path) -> None: """Long docstring must not hide markers (slow-path test).""" serializer = PythonNotebookSerializer() f = tmp_path / "app.py" - f.write_text('"""' + ("x" * 1024) + '"""\n' + "import marimo\napp = marimo.App()\n") + f.write_text( + '"""' + + ("x" * 1024) + + '"""\n' + + "import marimo\napp = marimo.App()\n" + ) assert serializer.is_marimo_notebook(f) is True def test_python_non_marimo_long(self, tmp_path: Path) -> None: @@ -597,7 +601,12 @@ def test_ipynb_empty_metadata(self, tmp_path: Path) -> None: serializer = IpynbNotebookSerializer() f = tmp_path / "empty.ipynb" - data = {"cells": [], "metadata": {}, "nbformat": 4, "nbformat_minor": 5} + data = { + "cells": [], + "metadata": {}, + "nbformat": 4, + "nbformat_minor": 5, + } f.write_text(json.dumps(data)) assert serializer.is_marimo_notebook(f) is False