diff --git a/docs/source/api.rst b/docs/source/api.rst index 1bf1ea2..ea0d49f 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -60,6 +60,7 @@ User Classes proj.uv.Uv proj.uv.UvScript proj.webapp.Django + proj.webapp.Marimo proj.webapp.Streamlit @@ -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 diff --git a/src/projspec/proj/__init__.py b/src/projspec/proj/__init__.py index fe000e6..a0f6bdd 100644 --- a/src/projspec/proj/__init__.py +++ b/src/projspec/proj/__init__.py @@ -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", @@ -25,6 +25,7 @@ "GitRepo", "JetbrainsIDE", "JLabExtension", + "Marimo", "MDBook", "NvidiaAIWorkbench", "Node", diff --git a/src/projspec/proj/webapp.py b/src/projspec/proj/webapp.py index e1c07db..63046b8 100644 --- a/src/projspec/proj/webapp.py +++ b/src/projspec/proj/webapp.py @@ -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( ) diff --git a/tests/test_marimo.py b/tests/test_marimo.py new file mode 100644 index 0000000..595d695 --- /dev/null +++ b/tests/test_marimo.py @@ -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/" diff --git a/tests/test_roundtrips.py b/tests/test_roundtrips.py index b044a5d..473f0a6 100644 --- a/tests/test_roundtrips.py +++ b/tests/test_roundtrips.py @@ -15,6 +15,7 @@ "IntakeCatalog", "DataPackage", "PyScript", + "marimo", ], ) def test_compliant(tmpdir, cls_name):