|
| 1 | +""" |
| 2 | +Build script for marimo notebooks. |
| 3 | +
|
| 4 | +This script exports marimo notebooks to HTML/WebAssembly format and generates |
| 5 | +an index.html file that lists all the notebooks. It handles both regular notebooks |
| 6 | +(from the notebooks/ directory) and apps (from the apps/ directory). |
| 7 | +
|
| 8 | +The script can be run from the command line with optional arguments: |
| 9 | + uv run .github/scripts/build.py [--output-dir OUTPUT_DIR] |
| 10 | +
|
| 11 | +The exported files will be placed in the specified output directory (default: _site). |
| 12 | +""" |
| 13 | + |
| 14 | +# /// script |
| 15 | +# requires-python = ">=3.12" |
| 16 | +# dependencies = [ |
| 17 | +# "jinja2==3.1.3", |
| 18 | +# "fire==0.7.0", |
| 19 | +# "loguru==0.7.0" |
| 20 | +# ] |
| 21 | +# /// |
| 22 | + |
| 23 | +import subprocess |
| 24 | +from pathlib import Path |
| 25 | +from typing import List, Union |
| 26 | + |
| 27 | +import fire |
| 28 | +import jinja2 |
| 29 | +from loguru import logger |
| 30 | + |
| 31 | + |
| 32 | +def _export_html_wasm( |
| 33 | + notebook_path: Path, output_dir: Path, as_app: bool = False |
| 34 | +) -> bool: |
| 35 | + """Export a single marimo notebook to HTML/WebAssembly format. |
| 36 | +
|
| 37 | + This function takes a marimo notebook (.py file) and exports it to HTML/WebAssembly format. |
| 38 | + If as_app is True, the notebook is exported in "run" mode with code hidden, suitable for |
| 39 | + applications. Otherwise, it's exported in "edit" mode, suitable for interactive notebooks. |
| 40 | +
|
| 41 | + Args: |
| 42 | + notebook_path (Path): Path to the marimo notebook (.py file) to export |
| 43 | + output_dir (Path): Directory where the exported HTML file will be saved |
| 44 | + as_app (bool, optional): Whether to export as an app (run mode) or notebook (edit mode). |
| 45 | + Defaults to False. |
| 46 | +
|
| 47 | + Returns: |
| 48 | + bool: True if export succeeded, False otherwise |
| 49 | + """ |
| 50 | + # Convert .py extension to .html for the output file |
| 51 | + output_path: Path = notebook_path.with_suffix(".html") |
| 52 | + |
| 53 | + # Base command for marimo export |
| 54 | + cmd: List[str] = ["uvx", "marimo", "export", "html-wasm", "--sandbox"] |
| 55 | + |
| 56 | + # Configure export mode based on whether it's an app or a notebook |
| 57 | + if as_app: |
| 58 | + logger.info(f"Exporting {notebook_path} to {output_path} as app") |
| 59 | + cmd.extend( |
| 60 | + ["--mode", "run", "--no-show-code"] |
| 61 | + ) # Apps run in "run" mode with hidden code |
| 62 | + else: |
| 63 | + logger.info(f"Exporting {notebook_path} to {output_path} as notebook") |
| 64 | + cmd.extend(["--mode", "edit"]) # Notebooks run in "edit" mode |
| 65 | + |
| 66 | + try: |
| 67 | + # Create full output path and ensure directory exists |
| 68 | + output_file: Path = output_dir / notebook_path.with_suffix(".html") |
| 69 | + output_file.parent.mkdir(parents=True, exist_ok=True) |
| 70 | + |
| 71 | + # Add notebook path and output file to command |
| 72 | + cmd.extend([str(notebook_path), "-o", str(output_file)]) |
| 73 | + |
| 74 | + # Run marimo export command |
| 75 | + logger.debug(f"Running command: {cmd}") |
| 76 | + subprocess.run(cmd, capture_output=True, text=True, check=True) |
| 77 | + logger.info(f"Successfully exported {notebook_path}") |
| 78 | + return True |
| 79 | + except subprocess.CalledProcessError as e: |
| 80 | + # Handle marimo export errors |
| 81 | + logger.error(f"Error exporting {notebook_path}:") |
| 82 | + logger.error(f"Command output: {e.stderr}") |
| 83 | + return False |
| 84 | + except Exception as e: |
| 85 | + # Handle unexpected errors |
| 86 | + logger.error(f"Unexpected error exporting {notebook_path}: {e}") |
| 87 | + return False |
| 88 | + |
| 89 | + |
| 90 | +def _generate_index( |
| 91 | + output_dir: Path, |
| 92 | + template_file: Path, |
| 93 | + notebooks_data: List[dict] | None = None, |
| 94 | + apps_data: List[dict] | None = None, |
| 95 | +) -> None: |
| 96 | + """Generate an index.html file that lists all the notebooks. |
| 97 | +
|
| 98 | + This function creates an HTML index page that displays links to all the exported |
| 99 | + notebooks. The index page includes the marimo logo and displays each notebook |
| 100 | + with a formatted title and a link to open it. |
| 101 | +
|
| 102 | + Args: |
| 103 | + notebooks_data (List[dict]): List of dictionaries with data for notebooks |
| 104 | + apps_data (List[dict]): List of dictionaries with data for apps |
| 105 | + output_dir (Path): Directory where the index.html file will be saved |
| 106 | + template_file (Path, optional): Path to the template file. If None, uses the default template. |
| 107 | +
|
| 108 | + Returns: |
| 109 | + None |
| 110 | + """ |
| 111 | + logger.info("Generating index.html") |
| 112 | + |
| 113 | + # Create the full path for the index.html file |
| 114 | + index_path: Path = output_dir / "index.html" |
| 115 | + |
| 116 | + # Ensure the output directory exists |
| 117 | + output_dir.mkdir(parents=True, exist_ok=True) |
| 118 | + |
| 119 | + try: |
| 120 | + # Set up Jinja2 environment and load template |
| 121 | + template_dir = template_file.parent |
| 122 | + template_name = template_file.name |
| 123 | + env = jinja2.Environment( |
| 124 | + loader=jinja2.FileSystemLoader(template_dir), |
| 125 | + autoescape=jinja2.select_autoescape(["html", "xml"]), |
| 126 | + ) |
| 127 | + template = env.get_template(template_name) |
| 128 | + |
| 129 | + # Render the template with notebook and app data |
| 130 | + rendered_html = template.render(notebooks=notebooks_data, apps=apps_data) |
| 131 | + |
| 132 | + # Write the rendered HTML to the index.html file |
| 133 | + with open(index_path, "w") as f: |
| 134 | + f.write(rendered_html) |
| 135 | + logger.info(f"Successfully generated index.html at {index_path}") |
| 136 | + |
| 137 | + except IOError as e: |
| 138 | + # Handle file I/O errors |
| 139 | + logger.error(f"Error generating index.html: {e}") |
| 140 | + except jinja2.exceptions.TemplateError as e: |
| 141 | + # Handle template errors |
| 142 | + logger.error(f"Error rendering template: {e}") |
| 143 | + |
| 144 | + |
| 145 | +def _export(folder: Path, output_dir: Path, as_app: bool = False) -> List[dict]: |
| 146 | + """Export all marimo notebooks in a folder to HTML/WebAssembly format. |
| 147 | +
|
| 148 | + This function finds all Python files in the specified folder and exports them |
| 149 | + to HTML/WebAssembly format using the export_html_wasm function. It returns a |
| 150 | + list of dictionaries containing the data needed for the template. |
| 151 | +
|
| 152 | + Args: |
| 153 | + folder (Path): Path to the folder containing marimo notebooks |
| 154 | + output_dir (Path): Directory where the exported HTML files will be saved |
| 155 | + as_app (bool, optional): Whether to export as apps (run mode) or notebooks (edit mode). |
| 156 | +
|
| 157 | + Returns: |
| 158 | + List[dict]: List of dictionaries with "display_name" and "html_path" for each notebook |
| 159 | + """ |
| 160 | + # Check if the folder exists |
| 161 | + if not folder.exists(): |
| 162 | + logger.warning(f"Directory not found: {folder}") |
| 163 | + return [] |
| 164 | + |
| 165 | + # Find all Python files recursively in the folder |
| 166 | + notebooks = list(folder.rglob("*.py")) |
| 167 | + logger.debug(f"Found {len(notebooks)} Python files in {folder}") |
| 168 | + |
| 169 | + # Exit if no notebooks were found |
| 170 | + if not notebooks: |
| 171 | + logger.warning(f"No notebooks found in {folder}!") |
| 172 | + return [] |
| 173 | + |
| 174 | + # For each successfully exported notebook, add its data to the notebook_data list |
| 175 | + notebook_data = [ |
| 176 | + { |
| 177 | + "display_name": (nb.stem.replace("_", " ").title()), |
| 178 | + "html_path": str(nb.with_suffix(".html")), |
| 179 | + } |
| 180 | + for nb in notebooks |
| 181 | + if _export_html_wasm(nb, output_dir, as_app=as_app) |
| 182 | + ] |
| 183 | + |
| 184 | + logger.info( |
| 185 | + f"Successfully exported {len(notebook_data)} out of {len(notebooks)} files from {folder}" |
| 186 | + ) |
| 187 | + return notebook_data |
| 188 | + |
| 189 | + |
| 190 | +def main( |
| 191 | + output_dir: Union[str, Path] = "_site", |
| 192 | + template: Union[str, Path] = "templates/tailwind.html.j2", |
| 193 | +) -> None: |
| 194 | + """Main function to export marimo notebooks. |
| 195 | +
|
| 196 | + This function: |
| 197 | + 1. Parses command line arguments |
| 198 | + 2. Exports all marimo notebooks in the 'notebooks' and 'apps' directories |
| 199 | + 3. Generates an index.html file that lists all the notebooks |
| 200 | +
|
| 201 | + Command line arguments: |
| 202 | + --output-dir: Directory where the exported files will be saved (default: _site) |
| 203 | + --template: Path to the template file (default: templates/index.html.j2) |
| 204 | +
|
| 205 | + Returns: |
| 206 | + None |
| 207 | + """ |
| 208 | + logger.info("Starting marimo build process") |
| 209 | + |
| 210 | + # Convert output_dir explicitly to Path (not done by fire) |
| 211 | + output_dir: Path = Path(output_dir) |
| 212 | + logger.info(f"Output directory: {output_dir}") |
| 213 | + |
| 214 | + # Make sure the output directory exists |
| 215 | + output_dir.mkdir(parents=True, exist_ok=True) |
| 216 | + |
| 217 | + # Convert template to Path if provided |
| 218 | + template_file: Path = Path(template) |
| 219 | + logger.info(f"Using template file: {template_file}") |
| 220 | + |
| 221 | + # Export notebooks from the notebooks/ directory |
| 222 | + notebooks_data = _export(Path("notebooks"), output_dir, as_app=False) |
| 223 | + |
| 224 | + # Export apps from the apps/ directory |
| 225 | + apps_data = _export(Path("apps"), output_dir, as_app=True) |
| 226 | + |
| 227 | + # Exit if no notebooks or apps were found |
| 228 | + if not notebooks_data and not apps_data: |
| 229 | + logger.warning("No notebooks or apps found!") |
| 230 | + return |
| 231 | + |
| 232 | + # Generate the index.html file that lists all notebooks and apps |
| 233 | + _generate_index( |
| 234 | + output_dir=output_dir, |
| 235 | + notebooks_data=notebooks_data, |
| 236 | + apps_data=apps_data, |
| 237 | + template_file=template_file, |
| 238 | + ) |
| 239 | + |
| 240 | + logger.info(f"Build completed successfully. Output directory: {output_dir}") |
| 241 | + |
| 242 | + |
| 243 | +if __name__ == "__main__": |
| 244 | + fire.Fire(main) |
0 commit comments