nb-lab-runtime 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
nb_lab/__init__.py ADDED
@@ -0,0 +1,28 @@
1
+ """nb-lab core package."""
2
+
3
+ from .cell_model import Cell, ParsedCell, assign_ids, load_sidecar, parse_cells, save_sidecar
4
+ from .executor import PythonExecutor
5
+ from .models import ExecutionResult, OutputItem
6
+ from .notebook_state import NotebookState
7
+ from .plot_meta import PlotMeta, capture_plot
8
+ from .server import create_app
9
+ from .watcher import PollingWatcher, WatchdogWatcher, build_watcher
10
+
11
+ __all__ = [
12
+ "Cell",
13
+ "ParsedCell",
14
+ "assign_ids",
15
+ "ExecutionResult",
16
+ "load_sidecar",
17
+ "OutputItem",
18
+ "parse_cells",
19
+ "PythonExecutor",
20
+ "save_sidecar",
21
+ "PlotMeta",
22
+ "capture_plot",
23
+ "create_app",
24
+ "NotebookState",
25
+ "PollingWatcher",
26
+ "WatchdogWatcher",
27
+ "build_watcher",
28
+ ]
nb_lab/cell_model.py ADDED
@@ -0,0 +1,299 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import uuid
7
+ from dataclasses import dataclass, field
8
+ from difflib import SequenceMatcher
9
+ from pathlib import Path
10
+ from typing import Any, Literal
11
+
12
+
13
+ CellKind = Literal["code", "markdown"]
14
+
15
+ _MARKER_RE = re.compile(r"^\s*#\s*%%(?:\s*\[(?P<header>.+?)\])?\s*$")
16
+
17
+
18
+ @dataclass
19
+ class Cell:
20
+ id: str
21
+ kind: CellKind
22
+ source: str
23
+ start_line: int
24
+ end_line: int
25
+ metadata: dict[str, Any] = field(default_factory=dict)
26
+
27
+
28
+ @dataclass
29
+ class ParsedCell:
30
+ kind: CellKind
31
+ source: str
32
+ start_line: int
33
+ end_line: int
34
+ metadata: dict[str, Any] = field(default_factory=dict)
35
+
36
+
37
+ @dataclass
38
+ class CellSnapshot:
39
+ id: str
40
+ kind: CellKind
41
+ source: str
42
+
43
+
44
+ def parse_cells(text: str) -> list[ParsedCell]:
45
+ lines = text.splitlines(keepends=True)
46
+ if not lines:
47
+ return []
48
+
49
+ markers: list[tuple[int, CellKind, dict[str, Any]]] = []
50
+ for idx, line in enumerate(lines):
51
+ match = _MARKER_RE.match(line)
52
+ if not match:
53
+ continue
54
+ header = (match.group("header") or "").strip().lower()
55
+ kind: CellKind = "markdown" if header in {"markdown", "md"} else "code"
56
+ metadata: dict[str, Any] = {}
57
+ if header:
58
+ metadata["header"] = header
59
+ markers.append((idx, kind, metadata))
60
+
61
+ if not markers:
62
+ return [
63
+ ParsedCell(
64
+ kind="code",
65
+ source="".join(lines),
66
+ start_line=1,
67
+ end_line=len(lines),
68
+ metadata={},
69
+ )
70
+ ]
71
+
72
+ cells: list[ParsedCell] = []
73
+
74
+ def build_cell(
75
+ kind: CellKind,
76
+ metadata: dict[str, Any],
77
+ start_idx: int,
78
+ end_idx: int,
79
+ fallback_line: int,
80
+ ) -> ParsedCell:
81
+ source = "".join(lines[start_idx:end_idx])
82
+ if source:
83
+ start_line = start_idx + 1
84
+ end_line = end_idx
85
+ else:
86
+ start_line = fallback_line
87
+ end_line = fallback_line
88
+ return ParsedCell(
89
+ kind=kind,
90
+ source=source,
91
+ start_line=start_line,
92
+ end_line=end_line,
93
+ metadata=metadata,
94
+ )
95
+
96
+ # Handle any content before the first marker as an implicit code cell.
97
+ first_marker_idx, first_kind, first_meta = markers[0]
98
+ if first_marker_idx > 0:
99
+ cells.append(
100
+ build_cell(
101
+ kind="code",
102
+ metadata={},
103
+ start_idx=0,
104
+ end_idx=first_marker_idx,
105
+ fallback_line=1,
106
+ )
107
+ )
108
+
109
+ for marker_pos, (marker_idx, kind, metadata) in enumerate(markers):
110
+ start_idx = marker_idx + 1
111
+ if marker_pos + 1 < len(markers):
112
+ end_idx = markers[marker_pos + 1][0]
113
+ else:
114
+ end_idx = len(lines)
115
+ fallback_line = marker_idx + 1
116
+ cells.append(build_cell(kind, metadata, start_idx, end_idx, fallback_line))
117
+
118
+ return cells
119
+
120
+
121
+ def assign_ids(
122
+ parsed_cells: list[ParsedCell],
123
+ previous: list[CellSnapshot],
124
+ similarity_threshold: float = 0.55,
125
+ ) -> list[Cell]:
126
+ remaining = list(enumerate(previous))
127
+ used_prev: set[int] = set()
128
+ cells: list[Cell] = []
129
+
130
+ for idx, parsed in enumerate(parsed_cells):
131
+ best_prev_index = None
132
+ best_score = 0.0
133
+ for prev_idx, prev in remaining:
134
+ if prev_idx in used_prev:
135
+ continue
136
+ if prev.kind != parsed.kind:
137
+ continue
138
+ score = _similarity_score(parsed.source, prev.source, parsed.kind, idx, prev_idx)
139
+ if score > best_score:
140
+ best_score = score
141
+ best_prev_index = prev_idx
142
+ if best_prev_index is not None and best_score >= similarity_threshold:
143
+ prev_cell = previous[best_prev_index]
144
+ used_prev.add(best_prev_index)
145
+ cell_id = prev_cell.id
146
+ else:
147
+ cell_id = uuid.uuid4().hex
148
+ cells.append(
149
+ Cell(
150
+ id=cell_id,
151
+ kind=parsed.kind,
152
+ source=parsed.source,
153
+ start_line=parsed.start_line,
154
+ end_line=parsed.end_line,
155
+ metadata=parsed.metadata,
156
+ )
157
+ )
158
+
159
+ return cells
160
+
161
+
162
+ def _similarity_score(
163
+ new_source: str, old_source: str, kind: CellKind, new_idx: int, old_idx: int
164
+ ) -> float:
165
+ normalized_new = _normalize_source(new_source, kind)
166
+ normalized_old = _normalize_source(old_source, kind)
167
+ ratio = SequenceMatcher(None, normalized_new, normalized_old).ratio()
168
+ # Favor local positional continuity for stability.
169
+ position_penalty = 0.02 * abs(new_idx - old_idx)
170
+ return max(0.0, ratio - position_penalty)
171
+
172
+
173
+ def _normalize_source(source: str, kind: CellKind) -> str:
174
+ lines = source.splitlines()
175
+ if kind == "code":
176
+ lines = [line for line in lines if not re.match(r"^\s*#", line)]
177
+ trimmed = [line.rstrip() for line in lines]
178
+ return "\n".join(trimmed).strip()
179
+
180
+
181
+ def sidecar_path(notebook_path: str | Path) -> Path:
182
+ path = Path(notebook_path)
183
+ digest = _cache_key(path)
184
+ cache_root = Path(
185
+ os.environ.get("NB_LAB_CACHE_DIR", Path.home() / ".cache" / "nb-lab")
186
+ )
187
+ return cache_root / f"{digest}.json"
188
+
189
+
190
+ def _cache_root() -> Path:
191
+ default_root = Path.home() / ".cache" / "nb-lab"
192
+ root = Path(os.environ.get("NB_LAB_CACHE_DIR", default_root))
193
+ if _is_writable(root):
194
+ return root
195
+ fallback = Path("/tmp/nb-lab-cache")
196
+ os.environ["NB_LAB_CACHE_DIR"] = str(fallback)
197
+ return fallback
198
+
199
+
200
+ def _is_writable(path: Path) -> bool:
201
+ try:
202
+ path.mkdir(parents=True, exist_ok=True)
203
+ test_file = path / ".write_test"
204
+ test_file.write_text("ok", encoding="utf-8")
205
+ test_file.unlink(missing_ok=True)
206
+ return True
207
+ except Exception:
208
+ return False
209
+
210
+
211
+ def _cache_key(path: Path) -> str:
212
+ return uuid.uuid5(uuid.NAMESPACE_URL, str(path.resolve())).hex
213
+
214
+
215
+ def load_sidecar(path: str | Path) -> list[CellSnapshot]:
216
+ sidecar = sidecar_path(path)
217
+ if not sidecar.exists():
218
+ return []
219
+ raw = json.loads(sidecar.read_text(encoding="utf-8"))
220
+ snapshots: list[CellSnapshot] = []
221
+ for item in raw.get("cells", []):
222
+ snapshots.append(
223
+ CellSnapshot(
224
+ id=item["id"],
225
+ kind=item["kind"],
226
+ source=item["source"],
227
+ )
228
+ )
229
+ return snapshots
230
+
231
+
232
+ def save_sidecar(path: str | Path, cells: list[Cell]) -> None:
233
+ path = Path(path)
234
+ sidecar = sidecar_path(path)
235
+ sidecar.parent.mkdir(parents=True, exist_ok=True)
236
+ payload = {
237
+ "cells": [
238
+ {"id": cell.id, "kind": cell.kind, "source": cell.source}
239
+ for cell in cells
240
+ ]
241
+ }
242
+ sidecar.write_text(json.dumps(payload, indent=2), encoding="utf-8")
243
+ meta = {"path": str(path.resolve())}
244
+ meta_path = sidecar.with_suffix(".meta.json")
245
+ meta_path.write_text(json.dumps(meta, indent=2), encoding="utf-8")
246
+
247
+
248
+ def cache_info() -> dict[str, object]:
249
+ root = _cache_root()
250
+ if not root.exists():
251
+ return {"dir": str(root), "entries": 0, "size_bytes": 0}
252
+ entries = [p for p in root.glob("*.json") if not p.name.endswith(".meta.json")]
253
+ size = sum(p.stat().st_size for p in entries)
254
+ return {"dir": str(root), "entries": len(entries), "size_bytes": size}
255
+
256
+
257
+ def cache_list() -> list[dict[str, object]]:
258
+ root = _cache_root()
259
+ results = []
260
+ if not root.exists():
261
+ return results
262
+ for sidecar in root.glob("*.json"):
263
+ if sidecar.name.endswith(".meta.json"):
264
+ continue
265
+ meta_path = sidecar.with_suffix(".meta.json")
266
+ meta = {}
267
+ if meta_path.exists():
268
+ try:
269
+ meta = json.loads(meta_path.read_text(encoding="utf-8"))
270
+ except Exception:
271
+ meta = {}
272
+ results.append(
273
+ {
274
+ "key": sidecar.stem,
275
+ "path": meta.get("path"),
276
+ "size_bytes": sidecar.stat().st_size,
277
+ "mtime": sidecar.stat().st_mtime,
278
+ }
279
+ )
280
+ return results
281
+
282
+
283
+ def cache_clear(path: str | Path | None = None) -> int:
284
+ root = _cache_root()
285
+ if not root.exists():
286
+ return 0
287
+ deleted = 0
288
+ if path is None:
289
+ for item in root.glob("*.json"):
290
+ item.unlink(missing_ok=True)
291
+ deleted += 1
292
+ return deleted
293
+ target = sidecar_path(path)
294
+ meta = target.with_suffix(".meta.json")
295
+ for item in (target, meta):
296
+ if item.exists():
297
+ item.unlink()
298
+ deleted += 1
299
+ return deleted
nb_lab/cli.py ADDED
@@ -0,0 +1,310 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+ from urllib import request
8
+
9
+ import uvicorn
10
+
11
+ from .cell_model import cache_clear, cache_info, cache_list
12
+ from .server import create_app
13
+
14
+
15
+ def _request_json(method: str, url: str, payload: dict | None = None) -> dict:
16
+ data = None
17
+ headers = {"Content-Type": "application/json"}
18
+ if payload is not None:
19
+ data = json.dumps(payload).encode("utf-8")
20
+ req = request.Request(url, data=data, headers=headers, method=method)
21
+ try:
22
+ with request.urlopen(req) as resp:
23
+ return json.loads(resp.read().decode("utf-8"))
24
+ except Exception as exc:
25
+ raise SystemExit(f"Request failed. Is the server running at {url}? ({exc})")
26
+
27
+
28
+ def _latest_version(base_url: str) -> int:
29
+ data = _request_json("GET", f"{base_url}/cells")
30
+ return int(data.get("version", 0))
31
+
32
+
33
+ def _cell_id_by_index(base_url: str, index: int) -> str:
34
+ data = _request_json("GET", f"{base_url}/cells")
35
+ cells = data.get("cells", [])
36
+ if index < 0 or index >= len(cells):
37
+ raise SystemExit(f"cell index {index} out of range")
38
+ return cells[index]["id"]
39
+
40
+
41
+ def cmd_serve(args: argparse.Namespace) -> None:
42
+ _ensure_mpl_config_dir()
43
+ app = create_app(args.path)
44
+ try:
45
+ uvicorn.run(app, host=args.host, port=args.port)
46
+ except OSError as exc:
47
+ if args.port != 0:
48
+ print(f"Port {args.port} unavailable ({exc}). Retrying on an ephemeral port.")
49
+ uvicorn.run(app, host=args.host, port=0)
50
+ else:
51
+ raise
52
+
53
+
54
+ def cmd_list(args: argparse.Namespace) -> None:
55
+ data = _request_json("GET", f"{args.base_url}/cells")
56
+ if args.compact:
57
+ print(json.dumps({"version": data.get("version"), "cells": data.get("cells", [])}, indent=2))
58
+ else:
59
+ print(json.dumps(data, indent=2))
60
+
61
+
62
+ def cmd_run(args: argparse.Namespace) -> None:
63
+ if args.cell_id is None and args.cell_index is None:
64
+ raise SystemExit("Provide either cell_id or --cell-index")
65
+ if args.cell_index is not None:
66
+ cell_id = _cell_id_by_index(args.base_url, args.cell_index)
67
+ else:
68
+ cell_id = args.cell_id
69
+ base_version = (
70
+ args.base_version if args.base_version is not None else _latest_version(args.base_url)
71
+ )
72
+ if args.force:
73
+ base_version = _latest_version(args.base_url)
74
+ payload = {
75
+ "mode": args.mode,
76
+ "timeout_ms": args.timeout_ms,
77
+ "base_version": base_version,
78
+ "include_images": not args.no_images,
79
+ }
80
+ data = _request_json("POST", f"{args.base_url}/cells/{cell_id}/run", payload)
81
+ print(json.dumps(data, indent=2))
82
+
83
+ def cmd_edit(args: argparse.Namespace) -> None:
84
+ if args.cell_id is None and args.cell_index is None:
85
+ raise SystemExit("Provide either cell_id or --cell-index")
86
+ if args.cell_index is not None:
87
+ cell_id = _cell_id_by_index(args.base_url, args.cell_index)
88
+ else:
89
+ cell_id = args.cell_id
90
+ base_version = (
91
+ args.base_version if args.base_version is not None else _latest_version(args.base_url)
92
+ )
93
+ if args.force:
94
+ base_version = _latest_version(args.base_url)
95
+ new_source = _resolve_new_source(args)
96
+ payload = {
97
+ "new_source": new_source,
98
+ "base_version": base_version,
99
+ }
100
+ data = _request_json("POST", f"{args.base_url}/cells/{cell_id}/edit", payload)
101
+ print(json.dumps(data, indent=2))
102
+
103
+
104
+ def cmd_insert(args: argparse.Namespace) -> None:
105
+ base_version = args.base_version if args.base_version is not None else _latest_version(args.base_url)
106
+ payload = {
107
+ "after_id": args.after_id,
108
+ "source": args.source,
109
+ "kind": args.kind,
110
+ "base_version": base_version,
111
+ }
112
+ data = _request_json("POST", f"{args.base_url}/cells/insert_after", payload)
113
+ print(json.dumps(data, indent=2))
114
+
115
+
116
+ def cmd_delete(args: argparse.Namespace) -> None:
117
+ if args.cell_id is None and args.cell_index is None:
118
+ raise SystemExit("Provide either cell_id or --cell-index")
119
+ if args.cell_index is not None:
120
+ cell_id = _cell_id_by_index(args.base_url, args.cell_index)
121
+ else:
122
+ cell_id = args.cell_id
123
+ base_version = (
124
+ args.base_version if args.base_version is not None else _latest_version(args.base_url)
125
+ )
126
+ if args.force:
127
+ base_version = _latest_version(args.base_url)
128
+ payload = {"base_version": base_version}
129
+ data = _request_json("DELETE", f"{args.base_url}/cells/{cell_id}", payload)
130
+ print(json.dumps(data, indent=2))
131
+
132
+
133
+ def cmd_show(args: argparse.Namespace) -> None:
134
+ data = _request_json("GET", f"{args.base_url}/outputs/{args.cell_id}")
135
+ if args.compact:
136
+ print(json.dumps(_compact_outputs(data), indent=2))
137
+ else:
138
+ print(json.dumps(data, indent=2))
139
+
140
+
141
+ def cmd_status(args: argparse.Namespace) -> None:
142
+ cells_data = _request_json("GET", f"{args.base_url}/cells")
143
+ status_data = _request_json("GET", f"{args.base_url}/status")
144
+ version = cells_data.get("version", 0)
145
+ cells = cells_data.get("cells", [])
146
+ dirty_ids = cells_data.get("dirty_ids", [])
147
+ output = {
148
+ "version": version,
149
+ "cell_count": len(cells),
150
+ "dirty_ids": dirty_ids,
151
+ "running_cell_id": status_data.get("running_cell_id"),
152
+ "running_since": status_data.get("running_since"),
153
+ }
154
+ print(json.dumps(output, indent=2))
155
+
156
+
157
+ def cmd_cache(args: argparse.Namespace) -> None:
158
+ if args.cache_action == "info":
159
+ data = cache_info()
160
+ print(json.dumps(data, indent=2))
161
+ return
162
+ if args.cache_action == "list":
163
+ data = cache_list()
164
+ print(json.dumps(data, indent=2))
165
+ return
166
+ if args.cache_action == "clear":
167
+ if args.all and args.path:
168
+ raise SystemExit("Use either --all or --path, not both.")
169
+ if args.all:
170
+ args.yes = True
171
+ args.path = None
172
+ if args.path is None:
173
+ if not args.yes:
174
+ raise SystemExit("Pass --yes to clear the entire cache.")
175
+ deleted = cache_clear()
176
+ else:
177
+ deleted = cache_clear(args.path)
178
+ print(json.dumps({"deleted": deleted}, indent=2))
179
+
180
+
181
+ def build_parser() -> argparse.ArgumentParser:
182
+ parser = argparse.ArgumentParser(prog="nb-lab")
183
+ sub = parser.add_subparsers(dest="command", required=True)
184
+
185
+ serve = sub.add_parser("serve")
186
+ serve.add_argument("path", type=str)
187
+ serve.add_argument("--host", default="127.0.0.1")
188
+ serve.add_argument("--port", default=8787, type=int)
189
+ serve.set_defaults(func=cmd_serve)
190
+
191
+ list_cmd = sub.add_parser("list")
192
+ list_cmd.add_argument("--base-url", default="http://127.0.0.1:8787")
193
+ list_cmd.add_argument("--compact", action="store_true")
194
+ list_cmd.set_defaults(func=cmd_list)
195
+
196
+ run_cmd = sub.add_parser("run")
197
+ run_cmd.add_argument("cell_id", nargs="?")
198
+ run_cmd.add_argument("--cell-index", type=int)
199
+ run_cmd.add_argument("--base-url", default="http://127.0.0.1:8787")
200
+ run_cmd.add_argument("--base-version", type=int)
201
+ run_cmd.add_argument("--timeout-ms", type=int, default=60000)
202
+ run_cmd.add_argument(
203
+ "--mode",
204
+ default="incremental",
205
+ choices=["incremental", "run_above", "single"],
206
+ )
207
+ run_cmd.add_argument("--no-images", action="store_true")
208
+ run_cmd.add_argument("--force", action="store_true")
209
+ run_cmd.set_defaults(func=cmd_run)
210
+
211
+ show_cmd = sub.add_parser("show")
212
+ show_cmd.add_argument("cell_id")
213
+ show_cmd.add_argument("--base-url", default="http://127.0.0.1:8787")
214
+ show_cmd.add_argument("--compact", action="store_true")
215
+ show_cmd.set_defaults(func=cmd_show)
216
+
217
+ status_cmd = sub.add_parser("status")
218
+ status_cmd.add_argument("--base-url", default="http://127.0.0.1:8787")
219
+ status_cmd.set_defaults(func=cmd_status)
220
+
221
+ cache_cmd = sub.add_parser("cache")
222
+ cache_cmd.add_argument("cache_action", choices=["info", "list", "clear"])
223
+ cache_cmd.add_argument("--path", default=None)
224
+ cache_cmd.add_argument("--yes", action="store_true")
225
+ cache_cmd.add_argument("--all", action="store_true")
226
+ cache_cmd.set_defaults(func=cmd_cache)
227
+
228
+ edit_cmd = sub.add_parser("edit")
229
+ edit_cmd.add_argument("cell_id", nargs="?")
230
+ edit_cmd.add_argument("--cell-index", type=int)
231
+ edit_cmd.add_argument("new_source", nargs="?")
232
+ edit_cmd.add_argument("--from-stdin", action="store_true")
233
+ edit_cmd.add_argument("--base-url", default="http://127.0.0.1:8787")
234
+ edit_cmd.add_argument("--base-version", type=int)
235
+ edit_cmd.add_argument("--force", action="store_true")
236
+ edit_cmd.set_defaults(func=cmd_edit)
237
+
238
+ insert_cmd = sub.add_parser("insert")
239
+ insert_cmd.add_argument("--after-id", default=None)
240
+ insert_cmd.add_argument("--kind", default="code", choices=["code", "markdown"])
241
+ insert_cmd.add_argument("--base-url", default="http://127.0.0.1:8787")
242
+ insert_cmd.add_argument("--base-version", type=int)
243
+ insert_cmd.add_argument("source")
244
+ insert_cmd.set_defaults(func=cmd_insert)
245
+
246
+ delete_cmd = sub.add_parser("delete")
247
+ delete_cmd.add_argument("cell_id", nargs="?")
248
+ delete_cmd.add_argument("--cell-index", type=int)
249
+ delete_cmd.add_argument("--base-url", default="http://127.0.0.1:8787")
250
+ delete_cmd.add_argument("--base-version", type=int)
251
+ delete_cmd.add_argument("--force", action="store_true")
252
+ delete_cmd.set_defaults(func=cmd_delete)
253
+
254
+ return parser
255
+
256
+
257
+ def main() -> None:
258
+ parser = build_parser()
259
+ args = parser.parse_args()
260
+ args.func(args)
261
+
262
+
263
+ def _resolve_new_source(args: argparse.Namespace) -> str:
264
+ if args.from_stdin:
265
+ return sys.stdin.read()
266
+ if args.new_source is None:
267
+ raise SystemExit("Provide new_source or use --from-stdin")
268
+ return args.new_source
269
+
270
+
271
+ def _ensure_mpl_config_dir() -> None:
272
+ import os
273
+ from pathlib import Path
274
+
275
+ if os.environ.get("MPLCONFIGDIR"):
276
+ return
277
+ default_dir = Path.home() / ".cache" / "matplotlib"
278
+ if _is_writable(default_dir):
279
+ os.environ["MPLCONFIGDIR"] = str(default_dir)
280
+ return
281
+ fallback = Path("/tmp/mpl-cache")
282
+ os.environ["MPLCONFIGDIR"] = str(fallback)
283
+
284
+
285
+ def _is_writable(path: Path) -> bool:
286
+ try:
287
+ path.mkdir(parents=True, exist_ok=True)
288
+ test_file = path / ".write_test"
289
+ test_file.write_text("ok", encoding="utf-8")
290
+ test_file.unlink(missing_ok=True)
291
+ return True
292
+ except Exception:
293
+ return False
294
+
295
+
296
+ def _compact_outputs(data: dict) -> dict:
297
+ result = data.get("result", {})
298
+ outputs = []
299
+ for item in result.get("outputs", []):
300
+ if item.get("type") == "image_png":
301
+ outputs.append({"type": "image_png", "data": "<omitted>"})
302
+ else:
303
+ outputs.append(item)
304
+ result["outputs"] = outputs
305
+ data["result"] = result
306
+ return data
307
+
308
+
309
+ if __name__ == "__main__":
310
+ main()