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.
- {stata_code-0.6.0 → stata_code-0.6.2}/PKG-INFO +3 -3
- {stata_code-0.6.0 → stata_code-0.6.2}/README.md +2 -2
- {stata_code-0.6.0 → stata_code-0.6.2}/pyproject.toml +1 -1
- {stata_code-0.6.0 → stata_code-0.6.2}/stata_code/__init__.py +1 -1
- {stata_code-0.6.0 → stata_code-0.6.2}/stata_code/core/notebook.py +34 -7
- {stata_code-0.6.0 → stata_code-0.6.2}/stata_code/mcp/server.py +68 -3
- {stata_code-0.6.0 → stata_code-0.6.2}/tests/test_mcp.py +89 -0
- {stata_code-0.6.0 → stata_code-0.6.2}/tests/test_notebook_phase2.py +134 -0
- {stata_code-0.6.0 → stata_code-0.6.2}/.gitignore +0 -0
- {stata_code-0.6.0 → stata_code-0.6.2}/CHANGELOG.md +0 -0
- {stata_code-0.6.0 → stata_code-0.6.2}/LICENSE +0 -0
- {stata_code-0.6.0 → stata_code-0.6.2}/LICENSE-POLICY.md +0 -0
- {stata_code-0.6.0 → stata_code-0.6.2}/PUBLISHING.md +0 -0
- {stata_code-0.6.0 → stata_code-0.6.2}/SCHEMA.md +0 -0
- {stata_code-0.6.0 → stata_code-0.6.2}/docs/design/hard_timeout.md +0 -0
- {stata_code-0.6.0 → stata_code-0.6.2}/examples/01-basic-regression.md +0 -0
- {stata_code-0.6.0 → stata_code-0.6.2}/examples/02-did-card-krueger.md +0 -0
- {stata_code-0.6.0 → stata_code-0.6.2}/examples/03-graphs.md +0 -0
- {stata_code-0.6.0 → stata_code-0.6.2}/examples/04-multi-session.md +0 -0
- {stata_code-0.6.0 → stata_code-0.6.2}/examples/05-large-matrix.md +0 -0
- {stata_code-0.6.0 → stata_code-0.6.2}/examples/README.md +0 -0
- {stata_code-0.6.0 → stata_code-0.6.2}/schema/run_result.schema.json +0 -0
- {stata_code-0.6.0 → stata_code-0.6.2}/scripts/export_schema.py +0 -0
- {stata_code-0.6.0 → stata_code-0.6.2}/stata_code/core/__init__.py +0 -0
- {stata_code-0.6.0 → stata_code-0.6.2}/stata_code/core/_pool.py +0 -0
- {stata_code-0.6.0 → stata_code-0.6.2}/stata_code/core/_refs.py +0 -0
- {stata_code-0.6.0 → stata_code-0.6.2}/stata_code/core/_runtime.py +0 -0
- {stata_code-0.6.0 → stata_code-0.6.2}/stata_code/core/errors.py +0 -0
- {stata_code-0.6.0 → stata_code-0.6.2}/stata_code/core/log_artifacts.py +0 -0
- {stata_code-0.6.0 → stata_code-0.6.2}/stata_code/core/run_index.py +0 -0
- {stata_code-0.6.0 → stata_code-0.6.2}/stata_code/core/runner.py +0 -0
- {stata_code-0.6.0 → stata_code-0.6.2}/stata_code/core/schema.py +0 -0
- {stata_code-0.6.0 → stata_code-0.6.2}/stata_code/kernel/__init__.py +0 -0
- {stata_code-0.6.0 → stata_code-0.6.2}/stata_code/kernel/__main__.py +0 -0
- {stata_code-0.6.0 → stata_code-0.6.2}/stata_code/kernel/assets/logo-32x32.png +0 -0
- {stata_code-0.6.0 → stata_code-0.6.2}/stata_code/kernel/assets/logo-64x64.png +0 -0
- {stata_code-0.6.0 → stata_code-0.6.2}/stata_code/kernel/assets/logo-svg.svg +0 -0
- {stata_code-0.6.0 → stata_code-0.6.2}/stata_code/kernel/kernel.py +0 -0
- {stata_code-0.6.0 → stata_code-0.6.2}/stata_code/mcp/__init__.py +0 -0
- {stata_code-0.6.0 → stata_code-0.6.2}/stata_code/mcp/__main__.py +0 -0
- {stata_code-0.6.0 → stata_code-0.6.2}/tests/__init__.py +0 -0
- {stata_code-0.6.0 → stata_code-0.6.2}/tests/fixtures/.gitkeep +0 -0
- {stata_code-0.6.0 → stata_code-0.6.2}/tests/test_cancel.py +0 -0
- {stata_code-0.6.0 → stata_code-0.6.2}/tests/test_errors.py +0 -0
- {stata_code-0.6.0 → stata_code-0.6.2}/tests/test_kernel.py +0 -0
- {stata_code-0.6.0 → stata_code-0.6.2}/tests/test_log_artifacts.py +0 -0
- {stata_code-0.6.0 → stata_code-0.6.2}/tests/test_notebook.py +0 -0
- {stata_code-0.6.0 → stata_code-0.6.2}/tests/test_pool.py +0 -0
- {stata_code-0.6.0 → stata_code-0.6.2}/tests/test_run_index.py +0 -0
- {stata_code-0.6.0 → stata_code-0.6.2}/tests/test_runner.py +0 -0
- {stata_code-0.6.0 → stata_code-0.6.2}/tests/test_runtime_discovery.py +0 -0
- {stata_code-0.6.0 → stata_code-0.6.2}/tests/test_schema.py +0 -0
- {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.
|
|
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
|
[](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
|
[](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**.
|
|
@@ -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.
|
|
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 =
|
|
1799
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|