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
8 changes: 5 additions & 3 deletions frontend/src/components/editor/header/filename-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/components/pages/home-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,8 @@ const Node = ({ node, style }: NodeRendererProps<FileInfo>) => {
? 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);
Expand Down
47 changes: 34 additions & 13 deletions marimo/_cli/files/file_path.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
# 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
Comment on lines +56 to 58
else:
Expand All @@ -67,12 +67,12 @@
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")
Expand Down Expand Up @@ -217,7 +217,11 @@

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)
Expand Down Expand Up @@ -313,15 +317,32 @@
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"
)

Check failure on line 343 in marimo/_cli/files/file_path.py

View workflow job for this annotation

GitHub Actions / Test lint and typecheck

ruff (B904)

marimo/_cli/files/file_path.py:337:17: B904 Within an `except` clause, raise exceptions with `raise ... from err` or `raise ... from None` to distinguish them from errors in exception handling
return name, None
Comment on lines 319 to +344

if path.suffix == ".html":
reader = StaticNotebookReader()
if reader.can_read(name):
Expand Down Expand Up @@ -391,7 +412,7 @@
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:
Expand Down
84 changes: 84 additions & 0 deletions marimo/_convert/ipynb/from_ir.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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,
*,
Expand Down
6 changes: 6 additions & 0 deletions marimo/_convert/ipynb/to_ir.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -1589,4 +1594,5 @@ def convert_from_ipynb_to_notebook_ir(
)
for cell in transformed_cells
],
filename=filepath,
)
3 changes: 2 additions & 1 deletion marimo/_server/api/endpoints/editing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions marimo/_server/asgi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
73 changes: 30 additions & 43 deletions marimo/_server/files/directory_scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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
Expand Down Expand Up @@ -142,9 +118,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.
Expand Down
9 changes: 6 additions & 3 deletions marimo/_server/files/os_file_system.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -202,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")
Expand Down
Loading
Loading