返回 JoyAI-Echo
notebook.py
1 """NotebookEditTool — edit Jupyter .ipynb notebooks."""
2
3 from __future__ import annotations
4
5 import json
6 import uuid
7 from typing import Any
8
9 from nanobot.agent.tools.base import tool_parameters
10 from nanobot.agent.tools.schema import IntegerSchema, StringSchema, tool_parameters_schema
11 from nanobot.agent.tools.filesystem import _FsTool
12
13
14 def _new_cell(source: str, cell_type: str = "code", generate_id: bool = False) -> dict:
15 cell: dict[str, Any] = {
16 "cell_type": cell_type,
17 "source": source,
18 "metadata": {},
19 }
20 if cell_type == "code":
21 cell["outputs"] = []
22 cell["execution_count"] = None
23 if generate_id:
24 cell["id"] = uuid.uuid4().hex[:8]
25 return cell
26
27
28 def _make_empty_notebook() -> dict:
29 return {
30 "nbformat": 4,
31 "nbformat_minor": 5,
32 "metadata": {
33 "kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"},
34 "language_info": {"name": "python"},
35 },
36 "cells": [],
37 }
38
39
40 @tool_parameters(
41 tool_parameters_schema(
42 path=StringSchema("Path to the .ipynb notebook file"),
43 cell_index=IntegerSchema(0, description="0-based index of the cell to edit", minimum=0),
44 new_source=StringSchema("New source content for the cell"),
45 cell_type=StringSchema(
46 "Cell type: 'code' or 'markdown' (default: code)",
47 enum=["code", "markdown"],
48 ),
49 edit_mode=StringSchema(
50 "Mode: 'replace' (default), 'insert' (after target), or 'delete'",
51 enum=["replace", "insert", "delete"],
52 ),
53 required=["path", "cell_index"],
54 )
55 )
56 class NotebookEditTool(_FsTool):
57 """Edit Jupyter notebook cells: replace, insert, or delete."""
58
59 _VALID_CELL_TYPES = frozenset({"code", "markdown"})
60 _VALID_EDIT_MODES = frozenset({"replace", "insert", "delete"})
61
62 @property
63 def name(self) -> str:
64 return "notebook_edit"
65
66 @property
67 def description(self) -> str:
68 return (
69 "Edit a Jupyter notebook (.ipynb) cell. "
70 "Modes: replace (default) replaces cell content, "
71 "insert adds a new cell after the target index, "
72 "delete removes the cell at the index. "
73 "cell_index is 0-based."
74 )
75
76 async def execute(
77 self,
78 path: str | None = None,
79 cell_index: int = 0,
80 new_source: str = "",
81 cell_type: str = "code",
82 edit_mode: str = "replace",
83 **kwargs: Any,
84 ) -> str:
85 try:
86 if not path:
87 return "Error: path is required"
88
89 if not path.endswith(".ipynb"):
90 return "Error: notebook_edit only works on .ipynb files. Use edit_file for other files."
91
92 if edit_mode not in self._VALID_EDIT_MODES:
93 return (
94 f"Error: Invalid edit_mode '{edit_mode}'. "
95 "Use one of: replace, insert, delete."
96 )
97
98 if cell_type not in self._VALID_CELL_TYPES:
99 return (
100 f"Error: Invalid cell_type '{cell_type}'. "
101 "Use one of: code, markdown."
102 )
103
104 fp = self._resolve(path)
105
106 # Create new notebook if file doesn't exist and mode is insert
107 if not fp.exists():
108 if edit_mode != "insert":
109 return f"Error: File not found: {path}"
110 nb = _make_empty_notebook()
111 cell = _new_cell(new_source, cell_type, generate_id=True)
112 nb["cells"].append(cell)
113 fp.parent.mkdir(parents=True, exist_ok=True)
114 fp.write_text(json.dumps(nb, indent=1, ensure_ascii=False), encoding="utf-8")
115 return f"Successfully created {fp} with 1 cell"
116
117 try:
118 nb = json.loads(fp.read_text(encoding="utf-8"))
119 except (json.JSONDecodeError, UnicodeDecodeError) as e:
120 return f"Error: Failed to parse notebook: {e}"
121
122 cells = nb.get("cells", [])
123 nbformat_minor = nb.get("nbformat_minor", 0)
124 generate_id = nb.get("nbformat", 0) >= 4 and nbformat_minor >= 5
125
126 if edit_mode == "delete":
127 if cell_index < 0 or cell_index >= len(cells):
128 return f"Error: cell_index {cell_index} out of range (notebook has {len(cells)} cells)"
129 cells.pop(cell_index)
130 nb["cells"] = cells
131 fp.write_text(json.dumps(nb, indent=1, ensure_ascii=False), encoding="utf-8")
132 return f"Successfully deleted cell {cell_index} from {fp}"
133
134 if edit_mode == "insert":
135 insert_at = min(cell_index + 1, len(cells))
136 cell = _new_cell(new_source, cell_type, generate_id=generate_id)
137 cells.insert(insert_at, cell)
138 nb["cells"] = cells
139 fp.write_text(json.dumps(nb, indent=1, ensure_ascii=False), encoding="utf-8")
140 return f"Successfully inserted cell at index {insert_at} in {fp}"
141
142 # Default: replace
143 if cell_index < 0 or cell_index >= len(cells):
144 return f"Error: cell_index {cell_index} out of range (notebook has {len(cells)} cells)"
145 cells[cell_index]["source"] = new_source
146 if cell_type and cells[cell_index].get("cell_type") != cell_type:
147 cells[cell_index]["cell_type"] = cell_type
148 if cell_type == "code":
149 cells[cell_index].setdefault("outputs", [])
150 cells[cell_index].setdefault("execution_count", None)
151 elif "outputs" in cells[cell_index]:
152 del cells[cell_index]["outputs"]
153 cells[cell_index].pop("execution_count", None)
154 nb["cells"] = cells
155 fp.write_text(json.dumps(nb, indent=1, ensure_ascii=False), encoding="utf-8")
156 return f"Successfully edited cell {cell_index} in {fp}"
157
158 except PermissionError as e:
159 return f"Error: {e}"
160 except Exception as e:
161 return f"Error editing notebook: {e}"
162
162 lines PYTHON