From d43f0999894e7ecf82877eb3bb56e0a5c4f9d8bc Mon Sep 17 00:00:00 2001 From: Konstantin Taletskiy Date: Mon, 2 Feb 2026 11:00:43 -0800 Subject: [PATCH 1/3] Add Marimo project support with detection and parsing capabilities --- src/projspec/proj/__init__.py | 3 +- src/projspec/proj/webapp.py | 44 ++++++++++++ tests/test_marimo.py | 132 ++++++++++++++++++++++++++++++++++ 3 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 tests/test_marimo.py diff --git a/src/projspec/proj/__init__.py b/src/projspec/proj/__init__.py index 4f982b6..4179ea7 100644 --- a/src/projspec/proj/__init__.py +++ b/src/projspec/proj/__init__.py @@ -12,7 +12,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", @@ -24,6 +24,7 @@ "GitRepo", "JetbrainsIDE", "JLabExtension", + "Marimo", "MDBook", "NvidiaAIWorkbench", "Node", diff --git a/src/projspec/proj/webapp.py b/src/projspec/proj/webapp.py index c2e4a55..c178e0e 100644 --- a/src/projspec/proj/webapp.py +++ b/src/projspec/proj/webapp.py @@ -89,6 +89,50 @@ 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: + # marimo notebooks are .py files with specific imports at the top + pyfiles = [fn for fn in self.proj.basenames if fn.endswith(".py")] + if not pyfiles: + return False + # quick check for marimo import in any .py file + for fn in pyfiles: + path = self.proj.basenames[fn] + try: + with self.proj.fs.open(path, "rb") as f: + header = f.read(500) + if b"import marimo" in header or b"from marimo" in header: + return True + except OSError: + continue + return False + + def parse(self) -> None: + from projspec.artifact.process import Server + + # marimo notebooks contain `import marimo` and `marimo.App(` or `= App(` + pyfiles = self.proj.fs.glob(f"{self.proj.url}/**/*.py") + pycontent = self.proj.fs.cat(pyfiles) + self.artifacts["server"] = {} + for path, content in pycontent.items(): + 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.replace(self.proj.url, "").lstrip("/")], + ) + + if not self.artifacts["server"]: + raise ParseFailed("No marimo notebooks found") + + # 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/" From 64c88412bfa6b5239c73b6710fd224a24b22de6e Mon Sep 17 00:00:00 2001 From: Martin Durant Date: Thu, 19 Feb 2026 13:08:58 -0500 Subject: [PATCH 2/3] Use scanning --- src/projspec/proj/webapp.py | 46 +++++++++++++++++++++++-------------- tests/test_roundtrips.py | 1 + 2 files changed, 30 insertions(+), 17 deletions(-) diff --git a/src/projspec/proj/webapp.py b/src/projspec/proj/webapp.py index 57e6d61..63046b8 100644 --- a/src/projspec/proj/webapp.py +++ b/src/projspec/proj/webapp.py @@ -141,30 +141,23 @@ class Marimo(ProjectSpec): spec_doc = "https://docs.marimo.io/" def match(self) -> bool: - # marimo notebooks are .py files with specific imports at the top - pyfiles = [fn for fn in self.proj.basenames if fn.endswith(".py")] + 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 - for fn in pyfiles: - path = self.proj.basenames[fn] - try: - with self.proj.fs.open(path, "rb") as f: - header = f.read(500) - if b"import marimo" in header or b"from marimo" in header: - return True - except OSError: - continue - return False + 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 - # marimo notebooks contain `import marimo` and `marimo.App(` or `= App(` - pyfiles = self.proj.fs.glob(f"{self.proj.url}/**/*.py") - pycontent = self.proj.fs.cat(pyfiles) self.artifacts["server"] = {} - for path, content in pycontent.items(): + 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 @@ -172,12 +165,31 @@ def parse(self) -> None: name = path.rsplit("/", 1)[-1].replace(".py", "") self.artifacts["server"][name] = Server( proj=self.proj, - cmd=["marimo", "run", path.replace(self.proj.url, "").lstrip("/")], + 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( ) 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): From 12b5719db7413d0935dcf1d083acc39e9c9d2de8 Mon Sep 17 00:00:00 2001 From: Martin Durant Date: Thu, 19 Feb 2026 14:32:03 -0500 Subject: [PATCH 3/3] Add to API doc --- docs/source/api.rst | 2 ++ 1 file changed, 2 insertions(+) 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