Skip to content
Merged
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
2 changes: 2 additions & 0 deletions docs/source/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ User Classes
proj.uv.Uv
proj.uv.UvScript
proj.webapp.Django
proj.webapp.Marimo
proj.webapp.Streamlit


Expand Down Expand Up @@ -95,6 +96,7 @@ User Classes
.. autoclass:: projspec.proj.uv.Uv
.. autoclass:: projspec.proj.uv.UvScript
.. autoclass:: projspec.proj.webapp.Django
.. autoclass:: projspec.proj.webapp.Marimo
.. autoclass:: projspec.proj.webapp.Streamlit


Expand Down
3 changes: 2 additions & 1 deletion src/projspec/proj/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from projspec.proj.python_code import PythonCode, PythonLibrary
from projspec.proj.rust import Rust, RustPython
from projspec.proj.uv import Uv
from projspec.proj.webapp import Django, Streamlit
from projspec.proj.webapp import Django, Marimo, Streamlit

__all__ = [
"ParseFailed",
Expand All @@ -25,6 +25,7 @@
"GitRepo",
"JetbrainsIDE",
"JLabExtension",
"Marimo",
"MDBook",
"NvidiaAIWorkbench",
"Node",
Expand Down
56 changes: 56 additions & 0 deletions src/projspec/proj/webapp.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,62 @@ def parse(self) -> None:
)


class Marimo(ProjectSpec):
"""Reactive Python notebook and webapp served in the browser"""

spec_doc = "https://docs.marimo.io/"

def match(self) -> bool:
pyfiles = {
data for fn, data in self.proj.scanned_files.items() if fn.endswith(".py")
}
if not pyfiles:
return False
# quick check for marimo import in any .py file
return any(
b"import marimo" in data or b"from marimo " in data for data in pyfiles
)

def parse(self) -> None:
from projspec.artifact.process import Server

self.artifacts["server"] = {}
for path, content in self.proj.scanned_files.items():
if not path.endswith(".py"):
continue
content = content.decode()
has_import = "import marimo" in content or "from marimo" in content
has_app = "marimo.App(" in content or "= App(" in content
if has_import and has_app:
name = path.rsplit("/", 1)[-1].replace(".py", "")
self.artifacts["server"][name] = Server(
proj=self.proj,
cmd=["marimo", "run", path],
)

if not self.artifacts["server"]:
raise ParseFailed("No marimo notebooks found")

@staticmethod
def _create(path):
with open(f"{path}/marimo-app.py", "wt") as f:
f.write(
"""
import marimo
__generated_with = "0.19.11"
app = marimo.App()

@app.cell
def _():
import marimo as mo
return "Hello, marimo!"

if __name__ == "__main__":
app.run()
"""
)


# TODO: the following are similar to streamlit, but with perhaps even less metadata
# - flask (from flask import Flask; app = Flask( )
# - fastapi (from fastapi import FastAPI; app = FastAPI( )
Expand Down
132 changes: 132 additions & 0 deletions tests/test_marimo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import os
import tempfile

import projspec
from projspec.proj.webapp import Marimo


# Sample marimo notebook content
MARIMO_NOTEBOOK = b"""import marimo

__generated_with = "0.18.4"
app = marimo.App()


@app.cell
def _():
import pandas as pd
return (pd,)


@app.cell
def _(pd):
df = pd.DataFrame({"a": [1, 2, 3]})
df
return (df,)


if __name__ == "__main__":
app.run()
"""

MARIMO_NOTEBOOK_ALT = b"""from marimo import App

app = App()


@app.cell
def _():
print("Hello, marimo!")
return


if __name__ == "__main__":
app.run()
"""

NOT_MARIMO = b"""import pandas as pd

def main():
df = pd.DataFrame({"a": [1, 2, 3]})
print(df)

if __name__ == "__main__":
main()
"""


def test_marimo_single_notebook():
"""Test detection of a single marimo notebook"""
with tempfile.TemporaryDirectory() as tmpdir:
# Create a marimo notebook
notebook_path = os.path.join(tmpdir, "notebook.py")
with open(notebook_path, "wb") as f:
f.write(MARIMO_NOTEBOOK)

proj = projspec.Project(tmpdir)
assert "marimo" in proj.specs
spec = proj.specs["marimo"]

# Should have server artifact
assert "server" in spec.artifacts
assert "notebook" in spec.artifacts["server"]

# Check the command
assert spec.artifacts["server"]["notebook"].cmd == [
"marimo",
"run",
"notebook.py",
]


def test_marimo_multiple_notebooks():
"""Test detection of multiple marimo notebooks"""
with tempfile.TemporaryDirectory() as tmpdir:
# Create multiple marimo notebooks
for name, content in [
("app1.py", MARIMO_NOTEBOOK),
("app2.py", MARIMO_NOTEBOOK_ALT),
]:
path = os.path.join(tmpdir, name)
with open(path, "wb") as f:
f.write(content)

proj = projspec.Project(tmpdir)
assert "marimo" in proj.specs
spec = proj.specs["marimo"]

# Should have nested artifacts for each notebook
assert isinstance(spec.artifacts["server"], dict)
assert "app1" in spec.artifacts["server"]
assert "app2" in spec.artifacts["server"]


def test_marimo_not_detected_for_regular_python():
"""Test that regular Python files are not detected as marimo"""
with tempfile.TemporaryDirectory() as tmpdir:
# Create a regular Python file
path = os.path.join(tmpdir, "script.py")
with open(path, "wb") as f:
f.write(NOT_MARIMO)

proj = projspec.Project(tmpdir)
assert "marimo" not in proj.specs


def test_marimo_match_requires_both_import_and_app():
"""Test that both import and App() are required for detection"""
with tempfile.TemporaryDirectory() as tmpdir:
# Create a file with just the import but no App
path = os.path.join(tmpdir, "partial.py")
with open(path, "wb") as f:
f.write(b"import marimo\n\nprint('hello')\n")

proj = projspec.Project(tmpdir)
# match() returns True (has import), but parse() should fail
# because there's no App pattern
assert "marimo" not in proj.specs


def test_marimo_spec_doc():
"""Test that spec_doc is set correctly"""
assert Marimo.spec_doc == "https://docs.marimo.io/"
1 change: 1 addition & 0 deletions tests/test_roundtrips.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"IntakeCatalog",
"DataPackage",
"PyScript",
"marimo",
],
)
def test_compliant(tmpdir, cls_name):
Expand Down