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
1 change: 1 addition & 0 deletions .claude/skills/al_to_notebook.md
72 changes: 72 additions & 0 deletions autoassistant/tests/test_to_notebook.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""Unit tests for the narrative-docstring script → notebook converter.

Fast and stdlib-only (json + tmp files): pins the cell-split semantics — docstring blocks
become markdown cells, code between becomes code cells — including the back-to-back
docstring case the PyAutoBuild reference pipeline mis-renders (two adjacent markdown
cells, never a code cell containing literal `# %%`/`'''` markers).
"""

from __future__ import annotations

import json

from autoassistant.to_notebook import convert, script_to_cells

SCRIPT = '''"""
Title
=====

Intro prose with __Contents__.
"""

import autolens as al

"""
__Section One__

Explains the next code block.
"""

tracer = None # code cell

"""
__Section Two__
"""

"""
__Back To Back__

A second docstring immediately after the first — must stay markdown.
"""

print("done")
'''


def test_cell_split_alternation():
cells = script_to_cells(SCRIPT)
kinds = [c["cell_type"] for c in cells]
assert kinds == [
"markdown", # title
"code", # import
"markdown", # section one
"code", # tracer
"markdown", # section two
"markdown", # back-to-back stays markdown
"code", # print
]
assert "__Section One__" in "".join(cells[2]["source"])
joined = "".join("".join(c["source"]) for c in cells)
assert "# %%" not in joined and "'''" not in joined


def test_convert_writes_valid_nbformat4(tmp_path):
script = tmp_path / "example.py"
script.write_text(SCRIPT, encoding="utf-8")
out = convert(script)
assert out == script.with_suffix(".ipynb")
nb = json.loads(out.read_text(encoding="utf-8"))
assert nb["nbformat"] == 4
assert all("id" in c for c in nb["cells"])
code = [c for c in nb["cells"] if c["cell_type"] == "code"]
assert all(c["outputs"] == [] and c["execution_count"] is None for c in code)
120 changes: 120 additions & 0 deletions autoassistant/to_notebook.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
"""Convert a narrative-docstring PyAutoLens script to a Jupyter notebook.

The assistant generates scripts in the workspace narrative-docstring style (an opening
title docstring with ``__Contents__``, then per-section ``\"\"\"__Section__\"\"\"``
docstrings — ``skills/_style.md`` "Generated script style"). That style was adopted
precisely because notebook conversion is mechanical:

- each **top-level docstring block** becomes a **markdown cell**;
- the **code between** docstring blocks becomes a **code cell**.

This module is a self-contained, stdlib-only adaptation of the converter the PyAuto
workspaces already use at build time — ``PyAutoBuild:autobuild/build_util.py``
(``py_to_notebook``) and ``PyAutoBuild:autobuild/add_notebook_quotes.py`` — which pipe
through the external ``ipynb-py-convert`` tool. It mirrors their cell-split semantics
(a line *starting* with triple quotes toggles docstring mode) but emits nbformat-v4 JSON
directly, so an assistant clone or a science project needs neither PyAutoBuild nor an
extra pip dependency.

Usage:

python -m autoassistant.to_notebook scripts/imaging.py # -> scripts/imaging.ipynb
python -m autoassistant.to_notebook scripts/imaging.py out.ipynb
"""

from __future__ import annotations

import json
import sys
from pathlib import Path

_TRIPLE = ('"""', "'''")


def _cell(cell_type: str, lines: list[str]) -> dict:
"""Build one nbformat-v4 cell; every source line keeps its newline except the last."""
while lines and lines[-1].strip() == "":
lines.pop()
while lines and lines[0].strip() == "":
lines.pop(0)
source = [line + "\n" for line in lines]
if source:
source[-1] = source[-1].rstrip("\n")
cell: dict = {"cell_type": cell_type, "metadata": {}, "source": source}
if cell_type == "code":
cell.update({"execution_count": None, "outputs": []})
return cell


def script_to_cells(text: str) -> list[dict]:
"""Split narrative-docstring script text into alternating markdown/code cells."""
cells: list[dict] = []
buffer: list[str] = []
in_docstring = False

def flush(cell_type: str) -> None:
nonlocal buffer
if any(line.strip() for line in buffer):
cells.append(_cell(cell_type, buffer))
buffer = []

for line in text.splitlines():
stripped = line.lstrip()
starts = stripped.startswith(_TRIPLE) and stripped == line # top-level only
if starts and not in_docstring and len(line) > 6 and line.rstrip().endswith(line[:3]):
# Single-line docstring, e.g. """__Section__""" — one markdown cell.
flush("code")
cells.append(_cell("markdown", [line.strip()[3:-3]]))
continue
if starts:
flush("markdown" if in_docstring else "code")
in_docstring = not in_docstring
continue
buffer.append(line)
flush("markdown" if in_docstring else "code")
return cells


def convert(script_path: Path, notebook_path: Path | None = None) -> Path:
"""Convert ``script_path`` to a notebook; returns the notebook path."""
script_path = Path(script_path)
if notebook_path is None:
notebook_path = script_path.with_suffix(".ipynb")
cells = script_to_cells(script_path.read_text(encoding="utf-8"))
for i, cell in enumerate(cells):
cell["id"] = f"cell-{i}"
notebook = {
"cells": cells,
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3",
},
"language_info": {"name": "python"},
},
"nbformat": 4,
"nbformat_minor": 5,
}
Path(notebook_path).write_text(
json.dumps(notebook, indent=1, ensure_ascii=False) + "\n", encoding="utf-8"
)
return Path(notebook_path)


def main(argv: list[str] | None = None) -> int:
args = list(sys.argv[1:] if argv is None else argv)
if not 1 <= len(args) <= 2:
print(__doc__.split("Usage:")[-1].strip(), file=sys.stderr)
return 1
script = Path(args[0])
if not script.is_file():
print(f"not a file: {script}", file=sys.stderr)
return 1
out = convert(script, Path(args[1]) if len(args) == 2 else None)
print(out.resolve())
return 0


if __name__ == "__main__":
raise SystemExit(main())
3 changes: 3 additions & 0 deletions skills/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@ them.
- [`al_inspect_source_reconstruction.md`](./al_inspect_source_reconstruction.md) —
inspect a pixelised inversion: regularisation, source-plane image, reconstruction
diagnostics.
- [`al_to_notebook.md`](./al_to_notebook.md) — convert a generated narrative-docstring
script to a Jupyter notebook (docstrings → markdown cells, code → code cells) via the
stdlib-only `autoassistant/to_notebook.py`.

### Pending — stubbed (need full recipes)

Expand Down
47 changes: 47 additions & 0 deletions skills/al_to_notebook.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
name: al_to_notebook
description: Convert a generated narrative-docstring PyAutoLens script (.py) into a Jupyter notebook (.ipynb) — each top-level docstring becomes a markdown cell, the code between becomes code cells. Use when the user says "turn this into a notebook", "convert to ipynb", or wants a Jupyter/Colab version of a script the assistant produced.
user-invocable: true
---

# Script → notebook

Generated scripts follow the workspace narrative-docstring style (`_style.md` "Generated
script style") precisely so this conversion is mechanical: each top-level `"""` docstring
block becomes a **markdown cell**; the Python between blocks becomes a **code cell**.

## Orient

The converter is `autoassistant/to_notebook.py` — stdlib-only, no external tools. It adapts
the converter the PyAuto workspaces use at build time (`PyAutoBuild:autobuild/build_util.py`
`py_to_notebook` + `add_notebook_quotes.py`) and mirrors its cell-split semantics. In a
science project, run it from the resolved assistant clone (refer-back).

## Ask

Nothing, usually — the script to convert is normally the one just produced. Ask only if
several candidate scripts are in play.

## Branch — convert

```bash
python -m autoassistant.to_notebook scripts/<name>.py # -> scripts/<name>.ipynb
python -m autoassistant.to_notebook scripts/<name>.py <out>.ipynb
```

(From a science project: `python <resolved-assistant>/autoassistant/to_notebook.py …` works
identically.) The CLI prints the absolute output path — quote it and offer to open it.

- The script must be in the narrative-docstring style; a script of bare comments converts to
one big code cell (correct, but probably not what the user wanted — say so).
- The notebook is **generated output**: never hand-edit the `.ipynb`; edit the `.py` and
reconvert. Keep the script the committed source of truth unless the user explicitly wants
the notebook tracked (e.g. a shared/Colab-facing artifact in a science project).

## Combine

- A converted notebook in a science project pairs well with the shareable-repo story
(`start-new-project.md` Collaborate/Publish): reviewers and collaborators often prefer
opening a notebook to running a script.
- For workspace-published notebooks (Colab setup cells, magic handling), the build pipeline
in PyAutoBuild remains authoritative — this skill is for assistant/project scripts.
Loading