stata-code 0.6.0__tar.gz → 0.6.1__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.1}/PKG-INFO +1 -1
  2. {stata_code-0.6.0 → stata_code-0.6.1}/pyproject.toml +1 -1
  3. {stata_code-0.6.0 → stata_code-0.6.1}/stata_code/__init__.py +1 -1
  4. {stata_code-0.6.0 → stata_code-0.6.1}/stata_code/core/notebook.py +34 -7
  5. {stata_code-0.6.0 → stata_code-0.6.1}/stata_code/mcp/server.py +54 -3
  6. {stata_code-0.6.0 → stata_code-0.6.1}/tests/test_mcp.py +89 -0
  7. {stata_code-0.6.0 → stata_code-0.6.1}/tests/test_notebook_phase2.py +134 -0
  8. {stata_code-0.6.0 → stata_code-0.6.1}/.gitignore +0 -0
  9. {stata_code-0.6.0 → stata_code-0.6.1}/CHANGELOG.md +0 -0
  10. {stata_code-0.6.0 → stata_code-0.6.1}/LICENSE +0 -0
  11. {stata_code-0.6.0 → stata_code-0.6.1}/LICENSE-POLICY.md +0 -0
  12. {stata_code-0.6.0 → stata_code-0.6.1}/PUBLISHING.md +0 -0
  13. {stata_code-0.6.0 → stata_code-0.6.1}/README.md +0 -0
  14. {stata_code-0.6.0 → stata_code-0.6.1}/SCHEMA.md +0 -0
  15. {stata_code-0.6.0 → stata_code-0.6.1}/docs/design/hard_timeout.md +0 -0
  16. {stata_code-0.6.0 → stata_code-0.6.1}/examples/01-basic-regression.md +0 -0
  17. {stata_code-0.6.0 → stata_code-0.6.1}/examples/02-did-card-krueger.md +0 -0
  18. {stata_code-0.6.0 → stata_code-0.6.1}/examples/03-graphs.md +0 -0
  19. {stata_code-0.6.0 → stata_code-0.6.1}/examples/04-multi-session.md +0 -0
  20. {stata_code-0.6.0 → stata_code-0.6.1}/examples/05-large-matrix.md +0 -0
  21. {stata_code-0.6.0 → stata_code-0.6.1}/examples/README.md +0 -0
  22. {stata_code-0.6.0 → stata_code-0.6.1}/schema/run_result.schema.json +0 -0
  23. {stata_code-0.6.0 → stata_code-0.6.1}/scripts/export_schema.py +0 -0
  24. {stata_code-0.6.0 → stata_code-0.6.1}/stata_code/core/__init__.py +0 -0
  25. {stata_code-0.6.0 → stata_code-0.6.1}/stata_code/core/_pool.py +0 -0
  26. {stata_code-0.6.0 → stata_code-0.6.1}/stata_code/core/_refs.py +0 -0
  27. {stata_code-0.6.0 → stata_code-0.6.1}/stata_code/core/_runtime.py +0 -0
  28. {stata_code-0.6.0 → stata_code-0.6.1}/stata_code/core/errors.py +0 -0
  29. {stata_code-0.6.0 → stata_code-0.6.1}/stata_code/core/log_artifacts.py +0 -0
  30. {stata_code-0.6.0 → stata_code-0.6.1}/stata_code/core/run_index.py +0 -0
  31. {stata_code-0.6.0 → stata_code-0.6.1}/stata_code/core/runner.py +0 -0
  32. {stata_code-0.6.0 → stata_code-0.6.1}/stata_code/core/schema.py +0 -0
  33. {stata_code-0.6.0 → stata_code-0.6.1}/stata_code/kernel/__init__.py +0 -0
  34. {stata_code-0.6.0 → stata_code-0.6.1}/stata_code/kernel/__main__.py +0 -0
  35. {stata_code-0.6.0 → stata_code-0.6.1}/stata_code/kernel/assets/logo-32x32.png +0 -0
  36. {stata_code-0.6.0 → stata_code-0.6.1}/stata_code/kernel/assets/logo-64x64.png +0 -0
  37. {stata_code-0.6.0 → stata_code-0.6.1}/stata_code/kernel/assets/logo-svg.svg +0 -0
  38. {stata_code-0.6.0 → stata_code-0.6.1}/stata_code/kernel/kernel.py +0 -0
  39. {stata_code-0.6.0 → stata_code-0.6.1}/stata_code/mcp/__init__.py +0 -0
  40. {stata_code-0.6.0 → stata_code-0.6.1}/stata_code/mcp/__main__.py +0 -0
  41. {stata_code-0.6.0 → stata_code-0.6.1}/tests/__init__.py +0 -0
  42. {stata_code-0.6.0 → stata_code-0.6.1}/tests/fixtures/.gitkeep +0 -0
  43. {stata_code-0.6.0 → stata_code-0.6.1}/tests/test_cancel.py +0 -0
  44. {stata_code-0.6.0 → stata_code-0.6.1}/tests/test_errors.py +0 -0
  45. {stata_code-0.6.0 → stata_code-0.6.1}/tests/test_kernel.py +0 -0
  46. {stata_code-0.6.0 → stata_code-0.6.1}/tests/test_log_artifacts.py +0 -0
  47. {stata_code-0.6.0 → stata_code-0.6.1}/tests/test_notebook.py +0 -0
  48. {stata_code-0.6.0 → stata_code-0.6.1}/tests/test_pool.py +0 -0
  49. {stata_code-0.6.0 → stata_code-0.6.1}/tests/test_run_index.py +0 -0
  50. {stata_code-0.6.0 → stata_code-0.6.1}/tests/test_runner.py +0 -0
  51. {stata_code-0.6.0 → stata_code-0.6.1}/tests/test_runtime_discovery.py +0 -0
  52. {stata_code-0.6.0 → stata_code-0.6.1}/tests/test_schema.py +0 -0
  53. {stata_code-0.6.0 → stata_code-0.6.1}/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.1
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
@@ -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.1"
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.1"
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 {
@@ -82,7 +82,7 @@ from stata_code.core.runner import (
82
82
  )
83
83
  from stata_code.core.schema import RunResult
84
84
 
85
- __version__ = "0.6.0"
85
+ __version__ = "0.6.1"
86
86
 
87
87
  SERVER_INSTRUCTIONS = (
88
88
  "Use stata-code for running and inspecting Stata code. Prefer structuredContent "
@@ -1661,11 +1661,29 @@ async def _dispatch(name: str, arguments: dict[str, Any]) -> Any:
1661
1661
  return _error_result(f"Error: {type(exc).__name__}: {exc}", kind="internal_error")
1662
1662
 
1663
1663
 
1664
+ _RUN_BOOL_KEYS: tuple[tuple[str, bool], ...] = (
1665
+ ("include_full_log", False),
1666
+ ("include_dataset_variables", True),
1667
+ ("persist_log_files", False),
1668
+ ("persist_generated_files", True),
1669
+ ("use_origin_workdir", True),
1670
+ )
1671
+
1672
+
1664
1673
  def _run_tool(arguments: dict[str, Any]) -> Any:
1665
1674
  args = dict(arguments)
1666
1675
  code = args.pop("code", None)
1667
1676
  if not code:
1668
1677
  return _error_result("code is required", kind="missing_argument")
1678
+ # Validate booleans up front rather than letting truthy strings ("false",
1679
+ # "no", …) silently flip behaviour inside the runner.
1680
+ for key, default in _RUN_BOOL_KEYS:
1681
+ if key not in args:
1682
+ continue
1683
+ value, err = _bool_arg(args, key, default=default)
1684
+ if err is not None:
1685
+ return err
1686
+ args[key] = value
1669
1687
  try:
1670
1688
  result = pool_execute(code, **args)
1671
1689
  except (ValueError, NotImplementedError) as exc:
@@ -1753,6 +1771,35 @@ def _require_str(arguments: dict[str, Any], key: str) -> tuple[str, Any]:
1753
1771
  return value, None
1754
1772
 
1755
1773
 
1774
+ _SENTINEL = object()
1775
+
1776
+
1777
+ def _bool_arg(
1778
+ arguments: dict[str, Any], key: str, *, default: bool
1779
+ ) -> tuple[bool, Any]:
1780
+ """Read an optional boolean argument with strict JSON-bool typing.
1781
+
1782
+ JSON has a real boolean type, and the inputSchema declares these args
1783
+ as ``"type": "boolean"``. Many MCP clients do not enforce the schema,
1784
+ so we reject coerced values like ``"true"``, ``1``, or ``"yes"`` here
1785
+ rather than silently truthy-coerce them (``bool("false") is True``).
1786
+
1787
+ ``isinstance(x, bool)`` happens to also gate against ``int`` even
1788
+ though ``bool`` is a subclass of ``int`` — only ``True`` / ``False``
1789
+ pass the check. Returns ``(value, None)`` on success, or
1790
+ ``(default, error_result)`` when the type is wrong.
1791
+ """
1792
+ value = arguments.get(key, _SENTINEL)
1793
+ if value is _SENTINEL or value is None:
1794
+ return default, None
1795
+ if not isinstance(value, bool):
1796
+ return default, _error_result(
1797
+ f"{key} must be a boolean (true/false), got {type(value).__name__}",
1798
+ kind="invalid_request",
1799
+ )
1800
+ return value, None
1801
+
1802
+
1756
1803
  def _notebook_edit_cell_tool(arguments: dict[str, Any]) -> Any:
1757
1804
  path, err = _require_str(arguments, "path")
1758
1805
  if err is not None:
@@ -1795,8 +1842,12 @@ def _notebook_insert_cell_tool(arguments: dict[str, Any]) -> Any:
1795
1842
  return _error_result("cell_type must be a string", kind="invalid_request")
1796
1843
  after_cell_id = arguments.get("after_cell_id")
1797
1844
  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)
1845
+ at_start, err = _bool_arg(arguments, "at_start", default=False)
1846
+ if err is not None:
1847
+ return err
1848
+ at_end, err = _bool_arg(arguments, "at_end", default=False)
1849
+ if err is not None:
1850
+ return err
1800
1851
  for label, value in (
1801
1852
  ("after_cell_id", after_cell_id),
1802
1853
  ("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