Skip to content

Commit 85a9369

Browse files
authored
Merge pull request #35 from ktaletsk/feature/marimo-support
Add Marimo project support with detection and parsing capabilities
2 parents 7a521be + 12b5719 commit 85a9369

File tree

5 files changed

+193
-1
lines changed

5 files changed

+193
-1
lines changed

docs/source/api.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ User Classes
6060
proj.uv.Uv
6161
proj.uv.UvScript
6262
proj.webapp.Django
63+
proj.webapp.Marimo
6364
proj.webapp.Streamlit
6465

6566

@@ -95,6 +96,7 @@ User Classes
9596
.. autoclass:: projspec.proj.uv.Uv
9697
.. autoclass:: projspec.proj.uv.UvScript
9798
.. autoclass:: projspec.proj.webapp.Django
99+
.. autoclass:: projspec.proj.webapp.Marimo
98100
.. autoclass:: projspec.proj.webapp.Streamlit
99101

100102

src/projspec/proj/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from projspec.proj.python_code import PythonCode, PythonLibrary
1414
from projspec.proj.rust import Rust, RustPython
1515
from projspec.proj.uv import Uv
16-
from projspec.proj.webapp import Django, Streamlit
16+
from projspec.proj.webapp import Django, Marimo, Streamlit
1717

1818
__all__ = [
1919
"ParseFailed",
@@ -25,6 +25,7 @@
2525
"GitRepo",
2626
"JetbrainsIDE",
2727
"JLabExtension",
28+
"Marimo",
2829
"MDBook",
2930
"NvidiaAIWorkbench",
3031
"Node",

src/projspec/proj/webapp.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,62 @@ def parse(self) -> None:
135135
)
136136

137137

138+
class Marimo(ProjectSpec):
139+
"""Reactive Python notebook and webapp served in the browser"""
140+
141+
spec_doc = "https://docs.marimo.io/"
142+
143+
def match(self) -> bool:
144+
pyfiles = {
145+
data for fn, data in self.proj.scanned_files.items() if fn.endswith(".py")
146+
}
147+
if not pyfiles:
148+
return False
149+
# quick check for marimo import in any .py file
150+
return any(
151+
b"import marimo" in data or b"from marimo " in data for data in pyfiles
152+
)
153+
154+
def parse(self) -> None:
155+
from projspec.artifact.process import Server
156+
157+
self.artifacts["server"] = {}
158+
for path, content in self.proj.scanned_files.items():
159+
if not path.endswith(".py"):
160+
continue
161+
content = content.decode()
162+
has_import = "import marimo" in content or "from marimo" in content
163+
has_app = "marimo.App(" in content or "= App(" in content
164+
if has_import and has_app:
165+
name = path.rsplit("/", 1)[-1].replace(".py", "")
166+
self.artifacts["server"][name] = Server(
167+
proj=self.proj,
168+
cmd=["marimo", "run", path],
169+
)
170+
171+
if not self.artifacts["server"]:
172+
raise ParseFailed("No marimo notebooks found")
173+
174+
@staticmethod
175+
def _create(path):
176+
with open(f"{path}/marimo-app.py", "wt") as f:
177+
f.write(
178+
"""
179+
import marimo
180+
__generated_with = "0.19.11"
181+
app = marimo.App()
182+
183+
@app.cell
184+
def _():
185+
import marimo as mo
186+
return "Hello, marimo!"
187+
188+
if __name__ == "__main__":
189+
app.run()
190+
"""
191+
)
192+
193+
138194
# TODO: the following are similar to streamlit, but with perhaps even less metadata
139195
# - flask (from flask import Flask; app = Flask( )
140196
# - fastapi (from fastapi import FastAPI; app = FastAPI( )

tests/test_marimo.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import os
2+
import tempfile
3+
4+
import projspec
5+
from projspec.proj.webapp import Marimo
6+
7+
8+
# Sample marimo notebook content
9+
MARIMO_NOTEBOOK = b"""import marimo
10+
11+
__generated_with = "0.18.4"
12+
app = marimo.App()
13+
14+
15+
@app.cell
16+
def _():
17+
import pandas as pd
18+
return (pd,)
19+
20+
21+
@app.cell
22+
def _(pd):
23+
df = pd.DataFrame({"a": [1, 2, 3]})
24+
df
25+
return (df,)
26+
27+
28+
if __name__ == "__main__":
29+
app.run()
30+
"""
31+
32+
MARIMO_NOTEBOOK_ALT = b"""from marimo import App
33+
34+
app = App()
35+
36+
37+
@app.cell
38+
def _():
39+
print("Hello, marimo!")
40+
return
41+
42+
43+
if __name__ == "__main__":
44+
app.run()
45+
"""
46+
47+
NOT_MARIMO = b"""import pandas as pd
48+
49+
def main():
50+
df = pd.DataFrame({"a": [1, 2, 3]})
51+
print(df)
52+
53+
if __name__ == "__main__":
54+
main()
55+
"""
56+
57+
58+
def test_marimo_single_notebook():
59+
"""Test detection of a single marimo notebook"""
60+
with tempfile.TemporaryDirectory() as tmpdir:
61+
# Create a marimo notebook
62+
notebook_path = os.path.join(tmpdir, "notebook.py")
63+
with open(notebook_path, "wb") as f:
64+
f.write(MARIMO_NOTEBOOK)
65+
66+
proj = projspec.Project(tmpdir)
67+
assert "marimo" in proj.specs
68+
spec = proj.specs["marimo"]
69+
70+
# Should have server artifact
71+
assert "server" in spec.artifacts
72+
assert "notebook" in spec.artifacts["server"]
73+
74+
# Check the command
75+
assert spec.artifacts["server"]["notebook"].cmd == [
76+
"marimo",
77+
"run",
78+
"notebook.py",
79+
]
80+
81+
82+
def test_marimo_multiple_notebooks():
83+
"""Test detection of multiple marimo notebooks"""
84+
with tempfile.TemporaryDirectory() as tmpdir:
85+
# Create multiple marimo notebooks
86+
for name, content in [
87+
("app1.py", MARIMO_NOTEBOOK),
88+
("app2.py", MARIMO_NOTEBOOK_ALT),
89+
]:
90+
path = os.path.join(tmpdir, name)
91+
with open(path, "wb") as f:
92+
f.write(content)
93+
94+
proj = projspec.Project(tmpdir)
95+
assert "marimo" in proj.specs
96+
spec = proj.specs["marimo"]
97+
98+
# Should have nested artifacts for each notebook
99+
assert isinstance(spec.artifacts["server"], dict)
100+
assert "app1" in spec.artifacts["server"]
101+
assert "app2" in spec.artifacts["server"]
102+
103+
104+
def test_marimo_not_detected_for_regular_python():
105+
"""Test that regular Python files are not detected as marimo"""
106+
with tempfile.TemporaryDirectory() as tmpdir:
107+
# Create a regular Python file
108+
path = os.path.join(tmpdir, "script.py")
109+
with open(path, "wb") as f:
110+
f.write(NOT_MARIMO)
111+
112+
proj = projspec.Project(tmpdir)
113+
assert "marimo" not in proj.specs
114+
115+
116+
def test_marimo_match_requires_both_import_and_app():
117+
"""Test that both import and App() are required for detection"""
118+
with tempfile.TemporaryDirectory() as tmpdir:
119+
# Create a file with just the import but no App
120+
path = os.path.join(tmpdir, "partial.py")
121+
with open(path, "wb") as f:
122+
f.write(b"import marimo\n\nprint('hello')\n")
123+
124+
proj = projspec.Project(tmpdir)
125+
# match() returns True (has import), but parse() should fail
126+
# because there's no App pattern
127+
assert "marimo" not in proj.specs
128+
129+
130+
def test_marimo_spec_doc():
131+
"""Test that spec_doc is set correctly"""
132+
assert Marimo.spec_doc == "https://docs.marimo.io/"

tests/test_roundtrips.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"IntakeCatalog",
1616
"DataPackage",
1717
"PyScript",
18+
"marimo",
1819
],
1920
)
2021
def test_compliant(tmpdir, cls_name):

0 commit comments

Comments
 (0)