stata-code 0.6.0__tar.gz → 0.6.2__tar.gz

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.
Files changed (53) hide show
  1. {stata_code-0.6.0 → stata_code-0.6.2}/PKG-INFO +3 -3
  2. {stata_code-0.6.0 → stata_code-0.6.2}/README.md +2 -2
  3. {stata_code-0.6.0 → stata_code-0.6.2}/pyproject.toml +1 -1
  4. {stata_code-0.6.0 → stata_code-0.6.2}/stata_code/__init__.py +1 -1
  5. {stata_code-0.6.0 → stata_code-0.6.2}/stata_code/core/notebook.py +34 -7
  6. {stata_code-0.6.0 → stata_code-0.6.2}/stata_code/mcp/server.py +68 -3
  7. {stata_code-0.6.0 → stata_code-0.6.2}/tests/test_mcp.py +89 -0
  8. {stata_code-0.6.0 → stata_code-0.6.2}/tests/test_notebook_phase2.py +134 -0
  9. {stata_code-0.6.0 → stata_code-0.6.2}/.gitignore +0 -0
  10. {stata_code-0.6.0 → stata_code-0.6.2}/CHANGELOG.md +0 -0
  11. {stata_code-0.6.0 → stata_code-0.6.2}/LICENSE +0 -0
  12. {stata_code-0.6.0 → stata_code-0.6.2}/LICENSE-POLICY.md +0 -0
  13. {stata_code-0.6.0 → stata_code-0.6.2}/PUBLISHING.md +0 -0
  14. {stata_code-0.6.0 → stata_code-0.6.2}/SCHEMA.md +0 -0
  15. {stata_code-0.6.0 → stata_code-0.6.2}/docs/design/hard_timeout.md +0 -0
  16. {stata_code-0.6.0 → stata_code-0.6.2}/examples/01-basic-regression.md +0 -0
  17. {stata_code-0.6.0 → stata_code-0.6.2}/examples/02-did-card-krueger.md +0 -0
  18. {stata_code-0.6.0 → stata_code-0.6.2}/examples/03-graphs.md +0 -0
  19. {stata_code-0.6.0 → stata_code-0.6.2}/examples/04-multi-session.md +0 -0
  20. {stata_code-0.6.0 → stata_code-0.6.2}/examples/05-large-matrix.md +0 -0
  21. {stata_code-0.6.0 → stata_code-0.6.2}/examples/README.md +0 -0
  22. {stata_code-0.6.0 → stata_code-0.6.2}/schema/run_result.schema.json +0 -0
  23. {stata_code-0.6.0 → stata_code-0.6.2}/scripts/export_schema.py +0 -0
  24. {stata_code-0.6.0 → stata_code-0.6.2}/stata_code/core/__init__.py +0 -0
  25. {stata_code-0.6.0 → stata_code-0.6.2}/stata_code/core/_pool.py +0 -0
  26. {stata_code-0.6.0 → stata_code-0.6.2}/stata_code/core/_refs.py +0 -0
  27. {stata_code-0.6.0 → stata_code-0.6.2}/stata_code/core/_runtime.py +0 -0
  28. {stata_code-0.6.0 → stata_code-0.6.2}/stata_code/core/errors.py +0 -0
  29. {stata_code-0.6.0 → stata_code-0.6.2}/stata_code/core/log_artifacts.py +0 -0
  30. {stata_code-0.6.0 → stata_code-0.6.2}/stata_code/core/run_index.py +0 -0
  31. {stata_code-0.6.0 → stata_code-0.6.2}/stata_code/core/runner.py +0 -0
  32. {stata_code-0.6.0 → stata_code-0.6.2}/stata_code/core/schema.py +0 -0
  33. {stata_code-0.6.0 → stata_code-0.6.2}/stata_code/kernel/__init__.py +0 -0
  34. {stata_code-0.6.0 → stata_code-0.6.2}/stata_code/kernel/__main__.py +0 -0
  35. {stata_code-0.6.0 → stata_code-0.6.2}/stata_code/kernel/assets/logo-32x32.png +0 -0
  36. {stata_code-0.6.0 → stata_code-0.6.2}/stata_code/kernel/assets/logo-64x64.png +0 -0
  37. {stata_code-0.6.0 → stata_code-0.6.2}/stata_code/kernel/assets/logo-svg.svg +0 -0
  38. {stata_code-0.6.0 → stata_code-0.6.2}/stata_code/kernel/kernel.py +0 -0
  39. {stata_code-0.6.0 → stata_code-0.6.2}/stata_code/mcp/__init__.py +0 -0
  40. {stata_code-0.6.0 → stata_code-0.6.2}/stata_code/mcp/__main__.py +0 -0
  41. {stata_code-0.6.0 → stata_code-0.6.2}/tests/__init__.py +0 -0
  42. {stata_code-0.6.0 → stata_code-0.6.2}/tests/fixtures/.gitkeep +0 -0
  43. {stata_code-0.6.0 → stata_code-0.6.2}/tests/test_cancel.py +0 -0
  44. {stata_code-0.6.0 → stata_code-0.6.2}/tests/test_errors.py +0 -0
  45. {stata_code-0.6.0 → stata_code-0.6.2}/tests/test_kernel.py +0 -0
  46. {stata_code-0.6.0 → stata_code-0.6.2}/tests/test_log_artifacts.py +0 -0
  47. {stata_code-0.6.0 → stata_code-0.6.2}/tests/test_notebook.py +0 -0
  48. {stata_code-0.6.0 → stata_code-0.6.2}/tests/test_pool.py +0 -0
  49. {stata_code-0.6.0 → stata_code-0.6.2}/tests/test_run_index.py +0 -0
  50. {stata_code-0.6.0 → stata_code-0.6.2}/tests/test_runner.py +0 -0
  51. {stata_code-0.6.0 → stata_code-0.6.2}/tests/test_runtime_discovery.py +0 -0
  52. {stata_code-0.6.0 → stata_code-0.6.2}/tests/test_schema.py +0 -0
  53. {stata_code-0.6.0 → stata_code-0.6.2}/tests/test_schema_artifact.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stata-code
3
- Version: 0.6.0
3
+ Version: 0.6.2
4
4
  Summary: Agent-native Stata bridge — one core, multiple frontends (MCP, Jupyter, VSCode)
5
5
  Project-URL: Homepage, https://github.com/brycewang-stanford/stata-code
6
6
  Project-URL: Repository, https://github.com/brycewang-stanford/stata-code
@@ -37,7 +37,7 @@ Requires-Dist: mcp>=1.27; extra == 'mcp'
37
37
  Description-Content-Type: text/markdown
38
38
 
39
39
  <p align="center">
40
- <img src="branding/logo/horizontal@1024.png" alt="stata-code logo" width="520" />
40
+ <img src="https://raw.githubusercontent.com/brycewang-stanford/stata-code/main/branding/logo/horizontal@1024.png" alt="stata-code logo" width="520" />
41
41
  </p>
42
42
 
43
43
  <p align="center">
@@ -59,7 +59,7 @@ Description-Content-Type: text/markdown
59
59
  [![GitHub stars](https://img.shields.io/github/stars/brycewang-stanford/stata-code?style=social)](https://github.com/brycewang-stanford/stata-code)
60
60
 
61
61
  <p align="center">
62
- <img src="branding/github-instructions.png" alt="stata-code: agent-native Stata bridge — one Python core, multiple frontends (Jupyter kernel, MCP server, VS Code extension)" width="720" />
62
+ <img src="https://raw.githubusercontent.com/brycewang-stanford/stata-code/main/branding/github-instructions.png" alt="stata-code: agent-native Stata bridge — one Python core, multiple frontends (Jupyter kernel, MCP server, VS Code extension)" width="720" />
63
63
  </p>
64
64
 
65
65
  > Agent-native Stata bridge — **one Python core, multiple frontends**.
@@ -1,5 +1,5 @@
1
1
  <p align="center">
2
- <img src="branding/logo/horizontal@1024.png" alt="stata-code logo" width="520" />
2
+ <img src="https://raw.githubusercontent.com/brycewang-stanford/stata-code/main/branding/logo/horizontal@1024.png" alt="stata-code logo" width="520" />
3
3
  </p>
4
4
 
5
5
  <p align="center">
@@ -21,7 +21,7 @@
21
21
  [![GitHub stars](https://img.shields.io/github/stars/brycewang-stanford/stata-code?style=social)](https://github.com/brycewang-stanford/stata-code)
22
22
 
23
23
  <p align="center">
24
- <img src="branding/github-instructions.png" alt="stata-code: agent-native Stata bridge — one Python core, multiple frontends (Jupyter kernel, MCP server, VS Code extension)" width="720" />
24
+ <img src="https://raw.githubusercontent.com/brycewang-stanford/stata-code/main/branding/github-instructions.png" alt="stata-code: agent-native Stata bridge — one Python core, multiple frontends (Jupyter kernel, MCP server, VS Code extension)" width="720" />
25
25
  </p>
26
26
 
27
27
  > Agent-native Stata bridge — **one Python core, multiple frontends**.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "stata-code"
7
- version = "0.6.0"
7
+ version = "0.6.2"
8
8
  description = "Agent-native Stata bridge — one core, multiple frontends (MCP, Jupyter, VSCode)"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -58,7 +58,7 @@ from stata_code.core.schema import (
58
58
  # Convenience alias: `run(...)` == `execute(...)`.
59
59
  run = execute
60
60
 
61
- __version__ = "0.6.0"
61
+ __version__ = "0.6.2"
62
62
 
63
63
  __all__ = [
64
64
  # Primary entry points
@@ -777,6 +777,28 @@ def _ensure_native_id(cell: dict[str, Any], index: int, source: str) -> str:
777
777
  return new_id
778
778
 
779
779
 
780
+ def _upgrade_all_pre_45_ids(cells: list[Any]) -> int:
781
+ """Assign a fresh UUID to every cell that lacks a native ``id``.
782
+
783
+ Synthesised ids are derived from the cell's array index; any structural
784
+ mutation (insert / delete) shifts indices, silently invalidating every
785
+ synth id the caller is holding. Upgrading the whole notebook to nbformat
786
+ 4.5+ ids on first mutation makes every cell handle stable from then on.
787
+
788
+ Returns the number of cells that were upgraded.
789
+ """
790
+ upgraded = 0
791
+ for cell in cells:
792
+ if not isinstance(cell, dict):
793
+ continue
794
+ raw = cell.get("id")
795
+ if isinstance(raw, str) and raw:
796
+ continue
797
+ cell["id"] = _new_cell_id()
798
+ upgraded += 1
799
+ return upgraded
800
+
801
+
780
802
  def edit_cell(
781
803
  path: str | Path,
782
804
  *,
@@ -883,6 +905,8 @@ def insert_cell(
883
905
  nb = load_notebook(p)
884
906
  cells = nb["cells"]
885
907
 
908
+ # Resolve the anchor BEFORE upgrading other ids — the caller may have
909
+ # passed a synth id that depends on the current array indices.
886
910
  if at_start:
887
911
  target_index = 0
888
912
  elif at_end:
@@ -891,20 +915,19 @@ def insert_cell(
891
915
  anchor_index, anchor_cell = _resolve_cell(
892
916
  cells, cell_id=after_cell_id, cell_index=None
893
917
  )
894
- # If the anchor was a pre-4.5 cell addressed by its synth id, upgrade
895
- # to a real UUID. Otherwise the anchor's index-derived synth id would
896
- # change after insertion (silent foot-gun for the caller).
897
- anchor_source = _source_to_str(anchor_cell.get("source"))
898
- _ensure_native_id(anchor_cell, anchor_index, anchor_source)
899
918
  target_index = anchor_index + 1
900
919
  else:
901
920
  anchor_index, anchor_cell = _resolve_cell(
902
921
  cells, cell_id=before_cell_id, cell_index=None
903
922
  )
904
- anchor_source = _source_to_str(anchor_cell.get("source"))
905
- _ensure_native_id(anchor_cell, anchor_index, anchor_source)
906
923
  target_index = anchor_index
907
924
 
925
+ # Insertion shifts every cell at >= target_index by one slot, which
926
+ # would silently invalidate every synth id the caller is holding.
927
+ # Upgrade the whole notebook to nbformat 4.5+ ids in one shot so every
928
+ # cell handle remains stable across this and future mutations.
929
+ _upgrade_all_pre_45_ids(cells)
930
+
908
931
  new_id = _new_cell_id()
909
932
  new_cell = _build_cell(cell_type=cell_type, source=source, cell_id=new_id)
910
933
  cells.insert(target_index, new_cell)
@@ -949,6 +972,10 @@ def delete_cell(
949
972
  actual_id, synthesized = _cell_id(cell, index, current_source)
950
973
  ctype = _cell_type(cell)
951
974
  cells.pop(index)
975
+ # The pop shifts every later cell's array index, invalidating any synth
976
+ # ids the caller might still be holding for those cells. Upgrade them
977
+ # all to fresh UUIDs in one shot.
978
+ _upgrade_all_pre_45_ids(cells)
952
979
  _atomic_write_notebook(p, nb)
953
980
 
954
981
  return {
@@ -62,15 +62,29 @@ from stata_code.core._pool import get_default_pool, pool_execute, pool_stata_inf
62
62
  from stata_code.core._runtime import PystataNotAvailable, is_available
63
63
  from stata_code.core.notebook import (
64
64
  NotebookError,
65
+ )
66
+ from stata_code.core.notebook import (
65
67
  delete_cell as _notebook_delete_cell,
68
+ )
69
+ from stata_code.core.notebook import (
66
70
  edit_cell as _notebook_edit_cell,
71
+ )
72
+ from stata_code.core.notebook import (
67
73
  get_cell as _notebook_get_cell,
74
+ )
75
+ from stata_code.core.notebook import (
68
76
  insert_cell as _notebook_insert_cell,
77
+ )
78
+ from stata_code.core.notebook import (
69
79
  locate_cells as _notebook_locate_cells,
80
+ )
81
+ from stata_code.core.notebook import (
70
82
  outline_notebook as _notebook_outline,
71
83
  )
72
84
  from stata_code.core.run_index import (
73
85
  RunIndexError,
86
+ )
87
+ from stata_code.core.run_index import (
74
88
  list_runs as _list_runs,
75
89
  )
76
90
  from stata_code.core.runner import (
@@ -82,7 +96,7 @@ from stata_code.core.runner import (
82
96
  )
83
97
  from stata_code.core.schema import RunResult
84
98
 
85
- __version__ = "0.6.0"
99
+ __version__ = "0.6.2"
86
100
 
87
101
  SERVER_INSTRUCTIONS = (
88
102
  "Use stata-code for running and inspecting Stata code. Prefer structuredContent "
@@ -1661,11 +1675,29 @@ async def _dispatch(name: str, arguments: dict[str, Any]) -> Any:
1661
1675
  return _error_result(f"Error: {type(exc).__name__}: {exc}", kind="internal_error")
1662
1676
 
1663
1677
 
1678
+ _RUN_BOOL_KEYS: tuple[tuple[str, bool], ...] = (
1679
+ ("include_full_log", False),
1680
+ ("include_dataset_variables", True),
1681
+ ("persist_log_files", False),
1682
+ ("persist_generated_files", True),
1683
+ ("use_origin_workdir", True),
1684
+ )
1685
+
1686
+
1664
1687
  def _run_tool(arguments: dict[str, Any]) -> Any:
1665
1688
  args = dict(arguments)
1666
1689
  code = args.pop("code", None)
1667
1690
  if not code:
1668
1691
  return _error_result("code is required", kind="missing_argument")
1692
+ # Validate booleans up front rather than letting truthy strings ("false",
1693
+ # "no", …) silently flip behaviour inside the runner.
1694
+ for key, default in _RUN_BOOL_KEYS:
1695
+ if key not in args:
1696
+ continue
1697
+ value, err = _bool_arg(args, key, default=default)
1698
+ if err is not None:
1699
+ return err
1700
+ args[key] = value
1669
1701
  try:
1670
1702
  result = pool_execute(code, **args)
1671
1703
  except (ValueError, NotImplementedError) as exc:
@@ -1753,6 +1785,35 @@ def _require_str(arguments: dict[str, Any], key: str) -> tuple[str, Any]:
1753
1785
  return value, None
1754
1786
 
1755
1787
 
1788
+ _SENTINEL = object()
1789
+
1790
+
1791
+ def _bool_arg(
1792
+ arguments: dict[str, Any], key: str, *, default: bool
1793
+ ) -> tuple[bool, Any]:
1794
+ """Read an optional boolean argument with strict JSON-bool typing.
1795
+
1796
+ JSON has a real boolean type, and the inputSchema declares these args
1797
+ as ``"type": "boolean"``. Many MCP clients do not enforce the schema,
1798
+ so we reject coerced values like ``"true"``, ``1``, or ``"yes"`` here
1799
+ rather than silently truthy-coerce them (``bool("false") is True``).
1800
+
1801
+ ``isinstance(x, bool)`` happens to also gate against ``int`` even
1802
+ though ``bool`` is a subclass of ``int`` — only ``True`` / ``False``
1803
+ pass the check. Returns ``(value, None)`` on success, or
1804
+ ``(default, error_result)`` when the type is wrong.
1805
+ """
1806
+ value = arguments.get(key, _SENTINEL)
1807
+ if value is _SENTINEL or value is None:
1808
+ return default, None
1809
+ if not isinstance(value, bool):
1810
+ return default, _error_result(
1811
+ f"{key} must be a boolean (true/false), got {type(value).__name__}",
1812
+ kind="invalid_request",
1813
+ )
1814
+ return value, None
1815
+
1816
+
1756
1817
  def _notebook_edit_cell_tool(arguments: dict[str, Any]) -> Any:
1757
1818
  path, err = _require_str(arguments, "path")
1758
1819
  if err is not None:
@@ -1795,8 +1856,12 @@ def _notebook_insert_cell_tool(arguments: dict[str, Any]) -> Any:
1795
1856
  return _error_result("cell_type must be a string", kind="invalid_request")
1796
1857
  after_cell_id = arguments.get("after_cell_id")
1797
1858
  before_cell_id = arguments.get("before_cell_id")
1798
- at_start = bool(arguments.get("at_start") or False)
1799
- at_end = bool(arguments.get("at_end") or False)
1859
+ at_start, err = _bool_arg(arguments, "at_start", default=False)
1860
+ if err is not None:
1861
+ return err
1862
+ at_end, err = _bool_arg(arguments, "at_end", default=False)
1863
+ if err is not None:
1864
+ return err
1800
1865
  for label, value in (
1801
1866
  ("after_cell_id", after_cell_id),
1802
1867
  ("before_cell_id", before_cell_id),
@@ -292,6 +292,95 @@ class TestDispatch:
292
292
  body = _json_body(out)
293
293
  assert "error" in body
294
294
 
295
+ def test_stata_run_rejects_non_boolean_argument(self):
296
+ """The schema declares ``include_full_log`` as boolean, but many MCP
297
+ clients do not validate. Server must reject coerced strings rather
298
+ than silently truthy-coerce them (``bool("false") is True``).
299
+ """
300
+ from stata_code.mcp.server import _dispatch
301
+
302
+ out = asyncio.run(
303
+ _dispatch(
304
+ "stata_run", {"code": "display 1", "include_full_log": "false"}
305
+ )
306
+ )
307
+ assert out.isError is True
308
+ body = _json_body(out)
309
+ assert "include_full_log" in body["error"]
310
+ assert "boolean" in body["error"].lower()
311
+
312
+ def test_notebook_insert_cell_rejects_string_at_start(self, tmp_path):
313
+ from stata_code.mcp.server import _dispatch
314
+
315
+ nb_path = tmp_path / "nb.ipynb"
316
+ nb_path.write_text(
317
+ json.dumps(
318
+ {
319
+ "nbformat": 4,
320
+ "nbformat_minor": 5,
321
+ "metadata": {},
322
+ "cells": [
323
+ {
324
+ "cell_type": "code",
325
+ "id": "a",
326
+ "source": "x=1",
327
+ "metadata": {},
328
+ "outputs": [],
329
+ }
330
+ ],
331
+ }
332
+ ),
333
+ encoding="utf-8",
334
+ )
335
+
336
+ out = asyncio.run(
337
+ _dispatch(
338
+ "notebook_insert_cell",
339
+ {"path": str(nb_path), "source": "x=2", "at_start": "true"},
340
+ )
341
+ )
342
+ assert out.isError is True
343
+ body = _json_body(out)
344
+ assert "at_start" in body["error"]
345
+ # No cell was appended despite the truthy-looking string.
346
+ cells = json.loads(nb_path.read_text(encoding="utf-8"))["cells"]
347
+ assert len(cells) == 1
348
+
349
+ def test_notebook_insert_cell_accepts_bool_at_start(self, tmp_path):
350
+ from stata_code.mcp.server import _dispatch
351
+
352
+ nb_path = tmp_path / "nb.ipynb"
353
+ nb_path.write_text(
354
+ json.dumps(
355
+ {
356
+ "nbformat": 4,
357
+ "nbformat_minor": 5,
358
+ "metadata": {},
359
+ "cells": [
360
+ {
361
+ "cell_type": "code",
362
+ "id": "a",
363
+ "source": "x=1",
364
+ "metadata": {},
365
+ "outputs": [],
366
+ }
367
+ ],
368
+ }
369
+ ),
370
+ encoding="utf-8",
371
+ )
372
+
373
+ out = asyncio.run(
374
+ _dispatch(
375
+ "notebook_insert_cell",
376
+ {"path": str(nb_path), "source": "x=2", "at_start": True},
377
+ )
378
+ )
379
+ assert out.isError is not True
380
+ cells = json.loads(nb_path.read_text(encoding="utf-8"))["cells"]
381
+ assert len(cells) == 2
382
+ assert cells[0]["source"] == "x=2"
383
+
295
384
  def test_stata_info_unavailable_shape(self, monkeypatch):
296
385
  from stata_code.mcp import server
297
386
 
@@ -477,3 +477,137 @@ def test_edit_cell_no_temp_files_left_behind(tmp_path: Path) -> None:
477
477
  # Only the notebook file should remain.
478
478
  leftover = [p.name for p in tmp_path.iterdir()]
479
479
  assert leftover == ["nb.ipynb"]
480
+
481
+
482
+ # ─────────────────────────────────────────────────────────────────────────────
483
+ # Pre-4.5 id stability across structural mutations
484
+ # ─────────────────────────────────────────────────────────────────────────────
485
+
486
+
487
+ def test_insert_at_start_upgrades_all_synth_ids(tmp_path: Path) -> None:
488
+ """`at_start` shifts every existing cell down one slot, which silently
489
+ invalidates every index-derived synth id the caller is holding. Inserting
490
+ must upgrade every pre-4.5 cell to a fresh UUID so previously-cached
491
+ handles either still resolve (if the caller re-reads) or fail loudly.
492
+ """
493
+ path = _write_nb(
494
+ tmp_path,
495
+ [
496
+ {"cell_type": "code", "source": "x=1", "metadata": {}, "outputs": []},
497
+ {"cell_type": "code", "source": "x=2", "metadata": {}, "outputs": []},
498
+ {"cell_type": "markdown", "source": "# H", "metadata": {}},
499
+ ],
500
+ )
501
+ insert_cell(path, source="first", at_start=True)
502
+ cells = _read(path)["cells"]
503
+ assert len(cells) == 4
504
+ # Every cell now carries a real UUID — no synth ids survive on disk.
505
+ for cell in cells:
506
+ cid = cell["id"]
507
+ assert isinstance(cid, str)
508
+ assert not cid.startswith("synth-")
509
+ assert re.match(r"^[0-9a-f-]{36}$", cid)
510
+ # Ids are unique.
511
+ assert len({c["id"] for c in cells}) == 4
512
+
513
+
514
+ def test_insert_at_end_upgrades_all_synth_ids(tmp_path: Path) -> None:
515
+ path = _write_nb(
516
+ tmp_path,
517
+ [
518
+ {"cell_type": "code", "source": "x=1", "metadata": {}, "outputs": []},
519
+ {"cell_type": "code", "source": "x=2", "metadata": {}, "outputs": []},
520
+ ],
521
+ )
522
+ insert_cell(path, source="last", at_end=True)
523
+ cells = _read(path)["cells"]
524
+ for cell in cells:
525
+ assert not cell["id"].startswith("synth-")
526
+
527
+
528
+ def test_insert_after_upgrades_unrelated_synth_ids(tmp_path: Path) -> None:
529
+ """The anchor's synth id was already upgraded by prior versions, but
530
+ other synth-id cells past the insertion point would silently shift.
531
+ All cells must end up with stable native ids.
532
+ """
533
+ path = _write_nb(
534
+ tmp_path,
535
+ [
536
+ {"cell_type": "code", "source": "x=1", "metadata": {}, "outputs": []},
537
+ {"cell_type": "code", "source": "x=2", "metadata": {}, "outputs": []},
538
+ {"cell_type": "code", "source": "x=3", "metadata": {}, "outputs": []},
539
+ ],
540
+ )
541
+ out = outline_notebook(path)
542
+ anchor_synth = out["cells"][0]["cell_id"]
543
+ third_synth = out["cells"][2]["cell_id"]
544
+ assert anchor_synth.startswith("synth-")
545
+ assert third_synth.startswith("synth-")
546
+
547
+ insert_cell(path, source="x=99", after_cell_id=anchor_synth)
548
+
549
+ cells = _read(path)["cells"]
550
+ # Cell that was at index 2 ('x=3') is now at index 3 — its synth id
551
+ # would have changed silently. The upgrade made it stable.
552
+ assert cells[3]["source"] == "x=3"
553
+ assert not cells[3]["id"].startswith("synth-")
554
+ assert re.match(r"^[0-9a-f-]{36}$", cells[3]["id"])
555
+
556
+
557
+ def test_delete_cell_upgrades_remaining_synth_ids(tmp_path: Path) -> None:
558
+ path = _write_nb(
559
+ tmp_path,
560
+ [
561
+ {"cell_type": "code", "source": "drop", "metadata": {}, "outputs": []},
562
+ {"cell_type": "code", "source": "keep1", "metadata": {}, "outputs": []},
563
+ {"cell_type": "code", "source": "keep2", "metadata": {}, "outputs": []},
564
+ ],
565
+ )
566
+ out = outline_notebook(path)
567
+ target = out["cells"][0]["cell_id"]
568
+
569
+ delete_cell(path, cell_id=target)
570
+ cells = _read(path)["cells"]
571
+ assert [c["source"] for c in cells] == ["keep1", "keep2"]
572
+ for cell in cells:
573
+ assert not cell["id"].startswith("synth-")
574
+
575
+
576
+ def test_edit_cell_upgrades_target_synth_id_only(tmp_path: Path) -> None:
577
+ path = _write_nb(
578
+ tmp_path,
579
+ [
580
+ {"cell_type": "code", "source": "x=1", "metadata": {}, "outputs": []},
581
+ {"cell_type": "code", "source": "x=2", "metadata": {}, "outputs": []},
582
+ ],
583
+ )
584
+ out = outline_notebook(path)
585
+ target = out["cells"][0]["cell_id"]
586
+
587
+ edit_cell(path, cell_id=target, new_source="x=99")
588
+ cells = _read(path)["cells"]
589
+ assert not cells[0]["id"].startswith("synth-")
590
+ assert "id" not in cells[1]
591
+
592
+
593
+ def test_edit_cell_keeps_other_synth_ids_usable_for_same_outline(
594
+ tmp_path: Path,
595
+ ) -> None:
596
+ path = _write_nb(
597
+ tmp_path,
598
+ [
599
+ {"cell_type": "code", "source": "x=1", "metadata": {}, "outputs": []},
600
+ {"cell_type": "code", "source": "x=2", "metadata": {}, "outputs": []},
601
+ ],
602
+ )
603
+ out = outline_notebook(path)
604
+ first_id = out["cells"][0]["cell_id"]
605
+ second_id = out["cells"][1]["cell_id"]
606
+
607
+ edit_cell(path, cell_id=first_id, new_source="x=10")
608
+ edit_cell(path, cell_id=second_id, new_source="x=20")
609
+
610
+ cells = _read(path)["cells"]
611
+ assert [c["source"] for c in cells] == ["x=10", "x=20"]
612
+ assert not cells[0]["id"].startswith("synth-")
613
+ assert not cells[1]["id"].startswith("synth-")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes