Skip to content

Commit d154c07

Browse files
committed
Support converting unknown Python scripts to marimo notebooks
Enable `marimo convert script.py` to convert regular Python scripts.
1 parent 538f003 commit d154c07

13 files changed

+544
-9
lines changed

marimo/_cli/convert/commands.py

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,9 @@ def convert(
2828
filename: str,
2929
output: Optional[Path],
3030
) -> None:
31-
r"""Convert a Jupyter notebook or Markdown file to a marimo notebook.
31+
r"""Convert a Jupyter notebook, Markdown file, or unknown Python script to a marimo notebook.
3232
33-
The argument may be either a path to a local .ipynb/.md file,
33+
The argument may be either a path to a local .ipynb/.md/.py file,
3434
or an .ipynb/.md file hosted on GitHub.
3535
3636
Example usage:
@@ -41,8 +41,18 @@ def convert(
4141
4242
marimo convert your_nb.md -o your_nb.py
4343
44+
or
45+
46+
marimo convert script.py -o your_nb.py
47+
4448
Jupyter notebook conversion will strip out all outputs. Markdown cell
45-
conversion with occur on the presence of `{python}` code fences.
49+
conversion will occur on the presence of `{python}` code fences.
50+
51+
For .py files:
52+
- If the file is already a valid marimo notebook, no conversion is performed
53+
- Unknown Python scripts are converted by preserving the header (docstrings/comments)
54+
and splitting the code into cells, with __main__ blocks separated
55+
4656
After conversion, you can open the notebook in the editor:
4757
4858
marimo edit your_nb.py
@@ -53,15 +63,41 @@ def convert(
5363
"""
5464

5565
ext = Path(filename).suffix
56-
if ext not in (".ipynb", ".md", ".qmd"):
57-
raise click.UsageError("File must be an .ipynb or .md file")
66+
if ext not in (".ipynb", ".md", ".qmd", ".py"):
67+
raise click.UsageError("File must be an .ipynb, .md, or .py file")
5868

5969
text = load_external_file(filename, ext)
6070
if ext == ".ipynb":
6171
notebook = MarimoConvert.from_ipynb(text).to_py()
62-
else:
63-
assert ext in (".md", ".qmd")
72+
elif ext in (".md", ".qmd"):
6473
notebook = MarimoConvert.from_md(text).to_py()
74+
else:
75+
assert ext == ".py"
76+
# First check if it's already a valid marimo notebook
77+
from marimo._ast.parse import parse_notebook
78+
79+
try:
80+
parsed = parse_notebook(text)
81+
except SyntaxError:
82+
# File has syntax errors
83+
echo("File cannot be converted. It may have syntax errors.")
84+
return
85+
86+
if parsed and parsed.valid:
87+
# Already a valid marimo notebook
88+
echo("File is already a valid marimo notebook.")
89+
return
90+
91+
# Check if it has the violation indicating it's an unknown Python script
92+
if parsed and any(
93+
v.description == "Unknown content beyond header"
94+
for v in parsed.violations
95+
):
96+
notebook = MarimoConvert.from_unknown_py_script(text).to_py()
97+
else:
98+
# File has other issues (syntax errors, etc.)
99+
echo("File cannot be converted. It may have syntax errors.")
100+
return
65101

66102
if output:
67103
output_path = Path(output)

marimo/_convert/converters.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,22 @@ def from_py(source: str) -> MarimoConverterIntermediate:
4949
ir = parse_notebook(source) or EMPTY_NOTEBOOK_SERIALIZATION
5050
return MarimoConverterIntermediate(ir)
5151

52+
@staticmethod
53+
def from_unknown_py_script(source: str) -> MarimoConverterIntermediate:
54+
"""Convert from unknown Python script to marimo notebook.
55+
56+
This should only be used when the .py file is not already a valid
57+
marimo notebook.
58+
59+
Args:
60+
source: Unknown Python script source code string
61+
"""
62+
from marimo._convert.unknown_python import (
63+
convert_unknown_py_to_notebook_ir,
64+
)
65+
66+
return MarimoConvert.from_ir(convert_unknown_py_to_notebook_ir(source))
67+
5268
@staticmethod
5369
def from_md(source: str) -> MarimoConverterIntermediate:
5470
"""Convert from markdown source code.

marimo/_convert/unknown_python.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# Copyright 2024 Marimo. All rights reserved.
2+
"""Convert unknown Python scripts to marimo notebooks."""
3+
4+
from __future__ import annotations
5+
6+
from marimo._ast.parse import Parser
7+
from marimo._schemas.serialization import (
8+
AppInstantiation,
9+
CellDef,
10+
NotebookSerialization,
11+
)
12+
13+
14+
def convert_unknown_py_to_notebook_ir(source: str) -> NotebookSerialization:
15+
"""Convert an unknown Python script to marimo notebook IR.
16+
17+
This should only be called after verifying the file is not already
18+
a valid marimo notebook. It converts by:
19+
1. Preserving the header (docstrings/comments)
20+
2. Splitting on 'if __name__ == "__main__":' if present:
21+
- Everything before goes into the first cell
22+
- The main block is transformed into a second cell with:
23+
- 'if __name__ == "__main__":' replaced with 'def _main_():'
24+
- '_main_()' called at the end
25+
3. If no main block exists, all content goes into a single cell
26+
"""
27+
parser = Parser(source)
28+
body = parser.node_stack()
29+
cells: list[CellDef] = []
30+
header_result = parser.parse_header(body)
31+
32+
header = header_result.unwrap()
33+
remaining = parser.extractor.contents[len(header.value) :].strip()
34+
35+
if remaining:
36+
# Split on if __name__ == "__main__":
37+
main_pattern = 'if __name__ == "__main__":'
38+
if main_pattern in remaining:
39+
parts = remaining.split(main_pattern, 1)
40+
before_main = parts[0].strip()
41+
42+
# Replace the if __name__ == "__main__": with def _main_():
43+
main_block = "def _main_():" + parts[1] + "\n\n_main_()"
44+
45+
# Create cells
46+
if before_main:
47+
cells.append(
48+
CellDef(
49+
lineno=1,
50+
col_offset=0,
51+
end_lineno=1,
52+
end_col_offset=0,
53+
code=before_main,
54+
name="_",
55+
options={},
56+
)
57+
)
58+
59+
cells.append(
60+
CellDef(
61+
lineno=2,
62+
col_offset=0,
63+
end_lineno=2,
64+
end_col_offset=0,
65+
code=main_block,
66+
name="_",
67+
options={},
68+
)
69+
)
70+
else:
71+
# No main block, put everything in one cell
72+
cells.append(
73+
CellDef(
74+
lineno=1,
75+
col_offset=0,
76+
end_lineno=1,
77+
end_col_offset=0,
78+
code=remaining,
79+
name="_",
80+
options={},
81+
)
82+
)
83+
84+
return NotebookSerialization(
85+
header=header,
86+
version=None,
87+
app=AppInstantiation(),
88+
cells=cells,
89+
violations=header_result.violations,
90+
valid=False,
91+
)
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
"""Simple calculation script."""
2+
3+
import marimo
4+
5+
6+
app = marimo.App()
7+
8+
9+
@app.cell
10+
def _():
11+
x = 5
12+
y = 10
13+
result = x + y
14+
print(f"Result: {result}")
15+
return
16+
17+
18+
if __name__ == "__main__":
19+
app.run()
20+
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""A simple Python script."""
2+
3+
import marimo
4+
5+
6+
app = marimo.App()
7+
8+
9+
@app.cell
10+
def _():
11+
import sys
12+
13+
def main():
14+
print("Hello, World!")
15+
return main, sys
16+
17+
18+
@app.cell
19+
def _(main, sys):
20+
def _main_():
21+
main()
22+
sys.exit(0)
23+
24+
_main_()
25+
return
26+
27+
28+
if __name__ == "__main__":
29+
app.run()
30+

tests/_cli/test_cli_convert.py

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ def test_convert_invalid_file(tmp_path: Path) -> None:
167167
text=True,
168168
)
169169
assert p.returncode != 0
170-
assert "File must be an .ipynb or .md file" in p.stderr
170+
assert "File must be an .ipynb, .md, or .py file" in p.stderr
171171

172172
@staticmethod
173173
def test_convert_remote_ipynb(http_server: MockHTTPServer) -> None:
@@ -255,7 +255,7 @@ def test_convert_remote_invalid_file(http_server: MockHTTPServer) -> None:
255255
text=True,
256256
)
257257
assert p.returncode != 0
258-
assert "File must be an .ipynb or .md file" in p.stderr
258+
assert "File must be an .ipynb, .md, or .py file" in p.stderr
259259

260260
@staticmethod
261261
def test_convert_nonexistent_remote_file(
@@ -274,3 +274,82 @@ def test_convert_nonexistent_remote_file(
274274
assert p.returncode != 0
275275
# The error message will be from urllib.error.HTTPError
276276
assert "HTTP Error 404" in p.stderr or "Not Found" in p.stderr
277+
278+
@staticmethod
279+
def test_convert_existing_marimo_notebook(tmp_path: Path) -> None:
280+
"""Test that converting an existing marimo notebook prints a message."""
281+
marimo_path = tmp_path / "existing_marimo.py"
282+
marimo_content = """import marimo
283+
284+
__generated_with = "0.10.0"
285+
app = marimo.App()
286+
287+
288+
@app.cell
289+
def __():
290+
print("Hello from marimo!")
291+
return
292+
293+
294+
if __name__ == "__main__":
295+
app.run()
296+
"""
297+
marimo_path.write_text(marimo_content)
298+
299+
p = subprocess.run(
300+
["marimo", "convert", str(marimo_path)],
301+
capture_output=True,
302+
text=True,
303+
)
304+
assert p.returncode == 0
305+
assert "File is already a valid marimo notebook." in p.stdout
306+
307+
@staticmethod
308+
def test_convert_unknown_python_script(tmp_path: Path) -> None:
309+
"""Test converting an unknown Python script."""
310+
script_path = tmp_path / "script.py"
311+
script_content = '''"""A simple Python script."""
312+
313+
import sys
314+
315+
def main():
316+
print("Hello, World!")
317+
318+
if __name__ == "__main__":
319+
main()
320+
sys.exit(0)
321+
'''
322+
script_path.write_text(script_content)
323+
324+
p = subprocess.run(
325+
["marimo", "convert", str(script_path)],
326+
capture_output=True,
327+
text=True,
328+
)
329+
assert p.returncode == 0, p.stderr
330+
output = p.stdout
331+
output = re.sub(r"__generated_with = .*", "", output)
332+
snapshot("python_script_to_marimo.txt", output)
333+
334+
@staticmethod
335+
def test_convert_python_script_no_main(tmp_path: Path) -> None:
336+
"""Test converting a Python script without main block."""
337+
script_path = tmp_path / "simple_script.py"
338+
script_content = '''"""Simple calculation script."""
339+
340+
x = 5
341+
y = 10
342+
result = x + y
343+
print(f"Result: {result}")
344+
'''
345+
script_path.write_text(script_content)
346+
347+
p = subprocess.run(
348+
["marimo", "convert", str(script_path)],
349+
capture_output=True,
350+
text=True,
351+
)
352+
assert p.returncode == 0, p.stderr
353+
output = p.stdout
354+
output = re.sub(r"__generated_with = .*", "", output)
355+
snapshot("python_script_no_main_to_marimo.txt", output)
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
"""Script with empty main."""
2+
3+
import marimo
4+
5+
__generated_with = "0.0.0"
6+
app = marimo.App()
7+
8+
9+
@app.cell
10+
def _():
11+
data = [1, 2, 3, 4, 5]
12+
return
13+
14+
15+
@app.cell
16+
def _():
17+
def _main_():
18+
pass
19+
20+
_main_()
21+
return
22+
23+
24+
if __name__ == "__main__":
25+
app.run()
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import marimo
2+
3+
__generated_with = "0.0.0"
4+
app = marimo.App()
5+
6+
7+
@app.cell
8+
def _():
9+
x = 5
10+
y = 10
11+
print(x + y)
12+
return
13+
14+
15+
if __name__ == "__main__":
16+
app.run()

0 commit comments

Comments
 (0)