From 8a181e99dca00518177642eb9efa78e5fc1e7b18 Mon Sep 17 00:00:00 2001 From: koaning Date: Mon, 9 Mar 2026 13:12:59 +0100 Subject: [PATCH 1/3] Clean up mobuild for readability and educational value - Add comprehensive docstring and comments to _write_file explaining marimo internals (DAG, cells, InternalApp) - Fix indentation (8-space to 4-space) in _write_file - Rename shadowed 'app' variable to 'notebook' and 'internal' for clarity - Fix export marker stripping: use case-insensitive regex instead of case-specific replacement - Generate __all__ from exported code to make packages explicit about public API - Simplify CLI to accept single notebook file (not folder) for clarity - Remove unused imports (importlib, importlib.resources, typer, shutil) - Replace custom tmpdir fixture with pytest's built-in tmp_path - Make errors fail loudly instead of silently printing and continuing - Add comprehensive test for selective cell export with functions and classes Co-Authored-By: Claude Opus 4.6 --- src/mobuild/__init__.py | 147 ++++++++++++------ src/mobuild/__main__.py | 2 - .../{{cookiecutter.project_name}}/Makefile | 2 +- tests/proj2/__init__.py | 59 +++++++ tests/test_basics.py | 54 +++++-- uv.lock | 4 +- 6 files changed, 203 insertions(+), 65 deletions(-) create mode 100644 tests/proj2/__init__.py diff --git a/src/mobuild/__init__.py b/src/mobuild/__init__.py index 9dd2a77..75c2e01 100644 --- a/src/mobuild/__init__.py +++ b/src/mobuild/__init__.py @@ -1,72 +1,129 @@ +import ast +import hashlib +import importlib.util +import re +import sys + from cookiecutter.main import cookiecutter -import typer from marimo._ast.app import InternalApp from pathlib import Path -import importlib -import importlib.util -import importlib.resources as resources -import hashlib -import sys +import typer app = typer.Typer(no_args_is_help=True) +_EXPORT_MARKER = re.compile(r"^## export\s*$", re.IGNORECASE | re.MULTILINE) + + +def _collect_top_level_names(code: str) -> list[str]: + """Return public top-level names defined in *code* (skip _private names).""" + names = [] + for node in ast.parse(code).body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + if not node.name.startswith("_"): + names.append(node.name) + elif isinstance(node, ast.Assign): + for target in node.targets: + if isinstance(target, ast.Name) and not target.id.startswith("_"): + names.append(target.id) + elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name): + if not node.target.id.startswith("_"): + names.append(node.target.id) + return names + + def _write_file(file: Path, out_folder: Path): - abs_path = str(Path(file).resolve()) - mod_name = "mobuild_runtime_" + hashlib.sha1(abs_path.encode()).hexdigest()[:10] - spec = importlib.util.spec_from_file_location(mod_name, abs_path) - if spec is None or spec.loader is None: - raise ImportError(f"Could not load spec for {abs_path}") - module = importlib.util.module_from_spec(spec) - try: - sys.modules[mod_name] = module - spec.loader.exec_module(module) - finally: - # Ensure no global pollution - sys.modules.pop(mod_name, None) - - app = getattr(module, "app") - app = InternalApp(app) - order = app.execution_order - - codes = {k: v.code for k, v in app.graph.cells.items() if v.language=="python" and "## export" in v.code.lower()} - code_export = "" - for i in order: - if i in codes: - code_export += codes[i].replace("## Export", "") + "\n" - Path(out_folder / file.name).write_text(code_export) + """Extract exported code from a single marimo notebook and write it as a plain Python file. + + Marimo notebooks are valid Python files. Each notebook defines a global ``app`` + object (a ``marimo.App``) whose cells are decorated functions. Internally, marimo + represents these cells as a directed acyclic graph (DAG) where edges encode + data dependencies between cells. + + This function: + 1. Dynamically imports the notebook file to obtain its ``app`` object. + 2. Wraps it in ``InternalApp`` to access the cell graph and topological + execution order. + 3. Selects only cells whose source code contains the ``## EXPORT`` marker. + 4. Concatenates those cells in execution order (respecting the DAG) and + writes the result to ``out_folder/``. + 5. Prepends an ``__all__`` so the generated module only exposes the + public names that were explicitly exported. + + The ``## EXPORT`` marker itself is stripped from the output so the resulting + file is clean library code. + """ + abs_path = str(file.resolve()) + mod_name = "mobuild_runtime_" + hashlib.sha1(abs_path.encode()).hexdigest()[:10] + spec = importlib.util.spec_from_file_location(mod_name, abs_path) + if spec is None or spec.loader is None: + raise ImportError(f"Could not load spec for {abs_path}") + module = importlib.util.module_from_spec(spec) + try: + sys.modules[mod_name] = module + spec.loader.exec_module(module) + finally: + # Ensure no global pollution + sys.modules.pop(mod_name, None) + + # Every marimo notebook exposes an `app` object (a marimo.App instance). + notebook = getattr(module, "app") + + # InternalApp gives access to the cell DAG and the topological execution order. + internal = InternalApp(notebook) + + # execution_order is a list of cell IDs sorted so that dependencies come first. + order = internal.execution_order + + # internal.graph.cells maps cell_id -> CellData. Each CellData has: + # .code -- the Python source of that cell + # .language -- "python" or "markdown" (marimo supports markdown cells) + exported_cells = { + cell_id: cell.code + for cell_id, cell in internal.graph.cells.items() + if cell.language == "python" and _EXPORT_MARKER.search(cell.code) + } + + code_export = "" + for cell_id in order: + if cell_id in exported_cells: + cleaned = _EXPORT_MARKER.sub("", exported_cells[cell_id]) + code_export += cleaned + "\n" + + names = _collect_top_level_names(code_export) + if names: + code_export = "__all__ = " + repr(names) + "\n\n" + code_export + + (out_folder / file.name).write_text(code_export) + @app.command(no_args_is_help=True) -def export(input_folder: Path, output_folder: Path): - """Build a Python library from a folder of Marimo notebooks.""" - if not Path(input_folder).exists(): - typer.echo(f"Input folder {input_folder} does not exist") +def export(input_file: Path, output_folder: Path): + """Build a Python file from a single Marimo notebook.""" + input_file = Path(input_file) + output_folder = Path(output_folder) + if not input_file.exists(): + typer.echo(f"Input file {input_file} does not exist") raise typer.Exit(1) - files = Path(input_folder).glob("*.py") - out_folder = Path(output_folder) - out_folder.mkdir(parents=True, exist_ok=True) + output_folder.mkdir(parents=True, exist_ok=True) + _write_file(input_file, output_folder) - for file in files: - try: - _write_file(file, out_folder) - except (ImportError, AttributeError) as e: - typer.echo(f"Error loading {file}: {e}") - @app.command(no_args_is_help=True) def init(name: str, output_folder: Path = Path(".")): """Render a new project into the given output folder. Expects the template assets to live under the installed package at - `mobuild/(cookiecutter/` with a `cookiecutter.json`. + ``mobuild/static/cookiecutter/`` with a ``cookiecutter.json``. """ cookie_folder = Path(__file__).parent / "static" / "cookiecutter" cookiecutter( str(cookie_folder), - output_dir=str(output_folder), + output_dir=str(output_folder), extra_context={"project_name": name}, - no_input=True + no_input=True, ) + def runtime_sync(output_folder: Path): """Write the current marimo notebook to the output folder as a normal Python file.""" _write_file(__file__, output_folder) diff --git a/src/mobuild/__main__.py b/src/mobuild/__main__.py index f6e0443..7e1154b 100644 --- a/src/mobuild/__main__.py +++ b/src/mobuild/__main__.py @@ -1,6 +1,4 @@ -import typer from mobuild import app if __name__ == "__main__": app() - diff --git a/src/mobuild/static/cookiecutter/{{cookiecutter.project_name}}/Makefile b/src/mobuild/static/cookiecutter/{{cookiecutter.project_name}}/Makefile index e5e8c77..05f3e7f 100644 --- a/src/mobuild/static/cookiecutter/{{cookiecutter.project_name}}/Makefile +++ b/src/mobuild/static/cookiecutter/{{cookiecutter.project_name}}/Makefile @@ -12,4 +12,4 @@ clean: rm -rf .pytest_cache/ build: - uv run mobuild export nbs src + uv run mobuild export nbs/__init__.py src diff --git a/tests/proj2/__init__.py b/tests/proj2/__init__.py new file mode 100644 index 0000000..0a9b75f --- /dev/null +++ b/tests/proj2/__init__.py @@ -0,0 +1,59 @@ +import marimo + +__generated_with = "0.15.2" +app = marimo.App() + + +@app.cell +def _(): + import marimo as mo + return + + +@app.cell +def _(): + ## EXPORT + + def greet(name: str) -> str: + return f"Hello, {name}!" + return + + +@app.cell +def _(): + ## EXPORT + + class Tokenizer: + def __init__(self, sep: str = " "): + self.sep = sep + + def tokenize(self, text: str) -> list[str]: + return text.split(self.sep) + return + + +@app.cell +def _(): + def _private_helper(): + return 42 + return + + +@app.cell +def _(): + # This cell is for interactive exploration, not exported. + result = greet("world") + mo.md(f"Result: {result}") + return + + +@app.cell +def _(): + ## EXPORT + + DEFAULT_SEP = " " + return + + +if __name__ == "__main__": + app.run() diff --git a/tests/test_basics.py b/tests/test_basics.py index 4067edb..85e195d 100644 --- a/tests/test_basics.py +++ b/tests/test_basics.py @@ -1,25 +1,49 @@ -import shutil -import tempfile from mobuild import export, init -from pathlib import Path -import pytest -@pytest.fixture -def tmpdir(): - with tempfile.TemporaryDirectory() as tmpdirname: - yield Path(tmpdirname) -def test_basics(tmpdir): - out_dir = tmpdir / "proj1_out" +def test_basics(tmp_path): + out_dir = tmp_path / "proj1_out" out_dir.mkdir() - export(input_folder="tests/proj1/", output_folder=out_dir) + export(input_file="tests/proj1/__init__.py", output_folder=out_dir) out = (out_dir / "__init__.py").read_text() assert "a = 1" in out assert "b = 2" not in out + assert "## export" not in out.lower() # Marker should be stripped + assert '__all__' in out + assert "'a'" in out -def test_init(tmpdir): - out_dir = tmpdir / "output" - init(name="output", output_folder=tmpdir) - print(list(out_dir.glob("*"))) + +def test_selective_export(tmp_path): + """Only cells marked with ## EXPORT should appear in the output.""" + out_dir = tmp_path / "proj2_out" + out_dir.mkdir() + export(input_file="tests/proj2/__init__.py", output_folder=out_dir) + out = (out_dir / "__init__.py").read_text() + + # Exported definitions should be present + assert "def greet" in out + assert "class Tokenizer" in out + assert "DEFAULT_SEP" in out + + # Non-exported code should be absent + assert "_private_helper" not in out + assert "mo.md" not in out + assert 'result = greet("world")' not in out + + # Export markers should be stripped + assert "## export" not in out.lower() + + # __all__ should list only public exported names + assert "__all__" in out + assert "'greet'" in out + assert "'Tokenizer'" in out + assert "'DEFAULT_SEP'" in out + assert "_private_helper" not in out + + + +def test_init(tmp_path): + out_dir = tmp_path / "output" + init(name="output", output_folder=tmp_path) assert (out_dir / "nbs" / "__init__.py").exists() assert (out_dir / "pyproject.toml").exists() diff --git a/uv.lock b/uv.lock index 5730745..6323eb4 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.12" [[package]] @@ -348,7 +348,7 @@ wheels = [ [[package]] name = "mobuild" -version = "0.1.0" +version = "0.1.1" source = { editable = "." } dependencies = [ { name = "cookiecutter" }, From 9084a177e2a583261dac3cd52ca51364395af4df Mon Sep 17 00:00:00 2001 From: koaning Date: Mon, 9 Mar 2026 13:19:25 +0100 Subject: [PATCH 2/3] Replace justfile with Makefile, add pypi and install targets Co-Authored-By: Claude Opus 4.6 --- Makefile | 10 ++++++++++ justfile | 10 ---------- pyproject.toml | 3 +++ uv.lock | 41 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 54 insertions(+), 10 deletions(-) create mode 100644 Makefile delete mode 100644 justfile diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2b8805e --- /dev/null +++ b/Makefile @@ -0,0 +1,10 @@ +install: + uv venv --allow-existing + uv pip install -e ".[dev]" + +test: + uv run pytest + +pypi: + uv build + uv publish diff --git a/justfile b/justfile deleted file mode 100644 index f2b934e..0000000 --- a/justfile +++ /dev/null @@ -1,10 +0,0 @@ -install: - uv venv --allow-existing - source .venv/bin/activate - uv pip install -e . pytest marimo - -test: - uv run pytest - -clean: - rm .DS_Store diff --git a/pyproject.toml b/pyproject.toml index f241b67..8f856c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,9 @@ authors = [ requires-python = ">=3.12" dependencies = ["typer", "marimo", "cookiecutter"] +[project.optional-dependencies] +dev = ["pytest"] + [project.scripts] mobuild = "mobuild:app" diff --git a/uv.lock b/uv.lock index 6323eb4..cc310b2 100644 --- a/uv.lock +++ b/uv.lock @@ -168,6 +168,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "itsdangerous" version = "2.2.0" @@ -356,12 +365,19 @@ dependencies = [ { name = "typer" }, ] +[package.optional-dependencies] +dev = [ + { name = "pytest" }, +] + [package.metadata] requires-dist = [ { name = "cookiecutter" }, { name = "marimo" }, + { name = "pytest", marker = "extra == 'dev'" }, { name = "typer" }, ] +provides-extras = ["dev"] [[package]] name = "narwhals" @@ -390,6 +406,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/16/32/f8e3c85d1d5250232a5d3477a2a28cc291968ff175caeadaf3cc19ce0e4a/parso-0.8.5-py2.py3-none-any.whl", hash = "sha256:646204b5ee239c396d040b90f9e272e9a8017c630092bf59980beb62fd033887", size = 106668, upload-time = "2025-08-23T15:15:25.663Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "psutil" version = "7.0.0" @@ -427,6 +452,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e4/06/43084e6cbd4b3bc0e80f6be743b2e79fbc6eed8de9ad8c629939fa55d972/pymdown_extensions-10.16.1-py3-none-any.whl", hash = "sha256:d6ba157a6c03146a7fb122b2b9a121300056384eafeec9c9f9e584adfdb2a32d", size = 266178, upload-time = "2025-07-28T16:19:31.401Z" }, ] +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" From f34108bd92861e4de0908fc2ad176eae1e8c69cc Mon Sep 17 00:00:00 2001 From: koaning Date: Mon, 9 Mar 2026 13:22:56 +0100 Subject: [PATCH 3/3] Bump version to 0.2.0 Co-Authored-By: Claude Opus 4.6 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8f856c0..0156ebf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mobuild" -version = "0.1.1" +version = "0.2.0" description = "Add your description here" readme = "README.md" authors = [