stata-code 0.7.0__tar.gz → 0.7.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 (58) hide show
  1. {stata_code-0.7.0 → stata_code-0.7.1}/.gitignore +2 -0
  2. {stata_code-0.7.0 → stata_code-0.7.1}/CHANGELOG.md +33 -0
  3. {stata_code-0.7.0 → stata_code-0.7.1}/PKG-INFO +4 -4
  4. {stata_code-0.7.0 → stata_code-0.7.1}/README.md +3 -3
  5. {stata_code-0.7.0 → stata_code-0.7.1}/pyproject.toml +1 -1
  6. {stata_code-0.7.0 → stata_code-0.7.1}/stata_code/__init__.py +1 -1
  7. {stata_code-0.7.0 → stata_code-0.7.1}/stata_code/core/runner.py +40 -10
  8. {stata_code-0.7.0 → stata_code-0.7.1}/stata_code/kernel/kernel.py +32 -2
  9. {stata_code-0.7.0 → stata_code-0.7.1}/stata_code/mcp/server.py +1 -1
  10. {stata_code-0.7.0 → stata_code-0.7.1}/tests/test_kernel.py +113 -0
  11. {stata_code-0.7.0 → stata_code-0.7.1}/tests/test_runner.py +61 -0
  12. {stata_code-0.7.0 → stata_code-0.7.1}/LICENSE +0 -0
  13. {stata_code-0.7.0 → stata_code-0.7.1}/LICENSE-POLICY.md +0 -0
  14. {stata_code-0.7.0 → stata_code-0.7.1}/PUBLISHING.md +0 -0
  15. {stata_code-0.7.0 → stata_code-0.7.1}/SCHEMA.md +0 -0
  16. {stata_code-0.7.0 → stata_code-0.7.1}/docs/design/hard_timeout.md +0 -0
  17. {stata_code-0.7.0 → stata_code-0.7.1}/examples/01-basic-regression.md +0 -0
  18. {stata_code-0.7.0 → stata_code-0.7.1}/examples/02-did-card-krueger.md +0 -0
  19. {stata_code-0.7.0 → stata_code-0.7.1}/examples/03-graphs.md +0 -0
  20. {stata_code-0.7.0 → stata_code-0.7.1}/examples/04-multi-session.md +0 -0
  21. {stata_code-0.7.0 → stata_code-0.7.1}/examples/05-large-matrix.md +0 -0
  22. {stata_code-0.7.0 → stata_code-0.7.1}/examples/README.md +0 -0
  23. {stata_code-0.7.0 → stata_code-0.7.1}/schema/run_result.schema.json +0 -0
  24. {stata_code-0.7.0 → stata_code-0.7.1}/scripts/check_versions.py +0 -0
  25. {stata_code-0.7.0 → stata_code-0.7.1}/scripts/export_schema.py +0 -0
  26. {stata_code-0.7.0 → stata_code-0.7.1}/stata_code/core/__init__.py +0 -0
  27. {stata_code-0.7.0 → stata_code-0.7.1}/stata_code/core/_pool.py +0 -0
  28. {stata_code-0.7.0 → stata_code-0.7.1}/stata_code/core/_refs.py +0 -0
  29. {stata_code-0.7.0 → stata_code-0.7.1}/stata_code/core/_runtime.py +0 -0
  30. {stata_code-0.7.0 → stata_code-0.7.1}/stata_code/core/errors.py +0 -0
  31. {stata_code-0.7.0 → stata_code-0.7.1}/stata_code/core/log_artifacts.py +0 -0
  32. {stata_code-0.7.0 → stata_code-0.7.1}/stata_code/core/notebook.py +0 -0
  33. {stata_code-0.7.0 → stata_code-0.7.1}/stata_code/core/run_index.py +0 -0
  34. {stata_code-0.7.0 → stata_code-0.7.1}/stata_code/core/schema.py +0 -0
  35. {stata_code-0.7.0 → stata_code-0.7.1}/stata_code/kernel/__init__.py +0 -0
  36. {stata_code-0.7.0 → stata_code-0.7.1}/stata_code/kernel/__main__.py +0 -0
  37. {stata_code-0.7.0 → stata_code-0.7.1}/stata_code/kernel/assets/logo-32x32.png +0 -0
  38. {stata_code-0.7.0 → stata_code-0.7.1}/stata_code/kernel/assets/logo-64x64.png +0 -0
  39. {stata_code-0.7.0 → stata_code-0.7.1}/stata_code/kernel/assets/logo-svg.svg +0 -0
  40. {stata_code-0.7.0 → stata_code-0.7.1}/stata_code/mcp/__init__.py +0 -0
  41. {stata_code-0.7.0 → stata_code-0.7.1}/stata_code/mcp/__main__.py +0 -0
  42. {stata_code-0.7.0 → stata_code-0.7.1}/tests/__init__.py +0 -0
  43. {stata_code-0.7.0 → stata_code-0.7.1}/tests/conftest.py +0 -0
  44. {stata_code-0.7.0 → stata_code-0.7.1}/tests/fixtures/.gitkeep +0 -0
  45. {stata_code-0.7.0 → stata_code-0.7.1}/tests/test_cancel.py +0 -0
  46. {stata_code-0.7.0 → stata_code-0.7.1}/tests/test_errors.py +0 -0
  47. {stata_code-0.7.0 → stata_code-0.7.1}/tests/test_log_artifacts.py +0 -0
  48. {stata_code-0.7.0 → stata_code-0.7.1}/tests/test_mcp.py +0 -0
  49. {stata_code-0.7.0 → stata_code-0.7.1}/tests/test_mcp_stdio.py +0 -0
  50. {stata_code-0.7.0 → stata_code-0.7.1}/tests/test_notebook.py +0 -0
  51. {stata_code-0.7.0 → stata_code-0.7.1}/tests/test_notebook_phase2.py +0 -0
  52. {stata_code-0.7.0 → stata_code-0.7.1}/tests/test_pool.py +0 -0
  53. {stata_code-0.7.0 → stata_code-0.7.1}/tests/test_public_api.py +0 -0
  54. {stata_code-0.7.0 → stata_code-0.7.1}/tests/test_release_versions.py +0 -0
  55. {stata_code-0.7.0 → stata_code-0.7.1}/tests/test_run_index.py +0 -0
  56. {stata_code-0.7.0 → stata_code-0.7.1}/tests/test_runtime_discovery.py +0 -0
  57. {stata_code-0.7.0 → stata_code-0.7.1}/tests/test_schema.py +0 -0
  58. {stata_code-0.7.0 → stata_code-0.7.1}/tests/test_schema_artifact.py +0 -0
@@ -223,6 +223,8 @@ log-files/
223
223
  *.smcl
224
224
  *.dta
225
225
  !tests/fixtures/*.dta
226
+ # Graph-export artifact written by the runner graph-capture tests
227
+ stata_code_test_export.png
226
228
 
227
229
  # macOS
228
230
  .DS_Store
@@ -6,6 +6,39 @@ to semver-major.minor for the result schema (see `SCHEMA.md` §6).
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## 0.7.1 — 2026-06-19
10
+
11
+ ### Fixed
12
+
13
+ - **Jupyter kernel: graphs after the first cell now display.** Graph capture
14
+ detected new graphs by diffing in-memory graph names before/after a run.
15
+ Because Stata keeps only one graph per name and every unnamed graph command
16
+ overwrites the default `Graph` in place, the second and later cells of a
17
+ persistent session produced no name delta and their graphs were silently
18
+ dropped — only the first cell's graph ever rendered. Capture now also
19
+ re-exports any graph the cell's own source shows it (re)drew (every
20
+ `name(...)` target, plus the default `Graph` for any unnamed graph command),
21
+ so in-place redraws surface every time. The same fix covers repeated MCP
22
+ `stata_run` calls in one session. The graph-command detector was tightened
23
+ to distinguish drawing commands from `graph` utility subcommands (`export`,
24
+ `display`, `dir`, `drop`, …) so a utility-only cell no longer re-surfaces a
25
+ stale graph.
26
+ - **Jupyter kernel: no more duplicated code echo in cell output.** pystata
27
+ runs a multi-line cell as a temporary do-file, and Stata echoes every
28
+ submitted command (`. cmd` / `> continuation`) regardless of `echo=False`
29
+ (which only suppresses echo for a single inline command). For a cell with no
30
+ textual output (e.g. a graph) that echo was the *only* thing shown — a
31
+ useless repeat of the source already visible in the input cell. The kernel
32
+ now strips command-echo lines before streaming, keeping genuine command
33
+ output. The full log (with echo) is unchanged in `RunResult.log` for MCP /
34
+ agent consumers.
35
+
36
+ ### Changed
37
+
38
+ - **VS Code extension now ships a Marketplace icon** (coef-plot mark, Anthropic
39
+ palette on white) so the listing and Extensions sidebar render branded
40
+ artwork instead of the default placeholder.
41
+
9
42
  ## 0.7.0 — 2026-05-30
10
43
 
11
44
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stata-code
3
- Version: 0.7.0
3
+ Version: 0.7.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
@@ -84,9 +84,9 @@ Description-Content-Type: text/markdown
84
84
  └─────────────┘ └────────────┘ └─────────────────┘
85
85
  ```
86
86
 
87
- **Status: v0.6 (May 2026)** — the core, MCP server, Jupyter kernel, and VS Code extension work end-to-end against Stata 18 MP. The test suite covers schema, runner, MCP, kernel, notebook, run-index, subprocess-pool, and VS Code modules; CI also checks linting, type safety, schema generation, package metadata, and VSIX packaging. License: **MIT**.
87
+ **Status: v0.7 (May 2026)** — the core, MCP server, Jupyter kernel, and VS Code extension work end-to-end against Stata 18 MP. The test suite covers schema, runner, MCP, kernel, notebook, run-index, subprocess-pool, and VS Code modules; CI also checks linting, type safety, schema generation, package metadata, and VSIX packaging. License: **MIT**.
88
88
 
89
- Two workflows v0.6 explicitly supports for end users:
89
+ Two workflows the current release explicitly supports for end users:
90
90
 
91
91
  - **Run Stata code from a Jupyter notebook.** `pip install "stata-code[kernel]"` + `stata-code-kernel install --user` registers a **Stata** kernel that the Jupyter Notebook UI, JupyterLab, and the VS Code Jupyter extension all pick up by name. Cells render Stata logs, graphs, and warnings inline (the kernel logo bundled since v0.5 makes it appear in VS Code's kernel picker too). See [As a Jupyter Kernel](#as-a-jupyter-kernel).
92
92
  - **Optional agent "fix and rerun" loop.** `stata_run` returns typed `error.kind/line/context` plus `suggestions` on every failure. By default Claude Code only reports diagnostics — but if you explicitly say "fix this and rerun until it passes", the agent uses the same fields to edit your `.do` file and re-call `stata_run` until the run is green. The repair loop is **opt-in**: failed runs are diagnostics first, not automatic rewrite permission. See [Error Recovery in Agent Workflows](#error-recovery-in-agent-workflows).
@@ -444,7 +444,7 @@ stata_code/
444
444
 
445
445
  ## Roadmap
446
446
 
447
- ### Done (through v0.6 — May 2026)
447
+ ### Done (through v0.7 — May 2026)
448
448
 
449
449
  - v1.0 result schema ([SCHEMA.md](SCHEMA.md))
450
450
  - `pystata`-based runner with native-typed `r()`, `e()`, and matrices
@@ -45,9 +45,9 @@
45
45
  └─────────────┘ └────────────┘ └─────────────────┘
46
46
  ```
47
47
 
48
- **Status: v0.6 (May 2026)** — the core, MCP server, Jupyter kernel, and VS Code extension work end-to-end against Stata 18 MP. The test suite covers schema, runner, MCP, kernel, notebook, run-index, subprocess-pool, and VS Code modules; CI also checks linting, type safety, schema generation, package metadata, and VSIX packaging. License: **MIT**.
48
+ **Status: v0.7 (May 2026)** — the core, MCP server, Jupyter kernel, and VS Code extension work end-to-end against Stata 18 MP. The test suite covers schema, runner, MCP, kernel, notebook, run-index, subprocess-pool, and VS Code modules; CI also checks linting, type safety, schema generation, package metadata, and VSIX packaging. License: **MIT**.
49
49
 
50
- Two workflows v0.6 explicitly supports for end users:
50
+ Two workflows the current release explicitly supports for end users:
51
51
 
52
52
  - **Run Stata code from a Jupyter notebook.** `pip install "stata-code[kernel]"` + `stata-code-kernel install --user` registers a **Stata** kernel that the Jupyter Notebook UI, JupyterLab, and the VS Code Jupyter extension all pick up by name. Cells render Stata logs, graphs, and warnings inline (the kernel logo bundled since v0.5 makes it appear in VS Code's kernel picker too). See [As a Jupyter Kernel](#as-a-jupyter-kernel).
53
53
  - **Optional agent "fix and rerun" loop.** `stata_run` returns typed `error.kind/line/context` plus `suggestions` on every failure. By default Claude Code only reports diagnostics — but if you explicitly say "fix this and rerun until it passes", the agent uses the same fields to edit your `.do` file and re-call `stata_run` until the run is green. The repair loop is **opt-in**: failed runs are diagnostics first, not automatic rewrite permission. See [Error Recovery in Agent Workflows](#error-recovery-in-agent-workflows).
@@ -405,7 +405,7 @@ stata_code/
405
405
 
406
406
  ## Roadmap
407
407
 
408
- ### Done (through v0.6 — May 2026)
408
+ ### Done (through v0.7 — May 2026)
409
409
 
410
410
  - v1.0 result schema ([SCHEMA.md](SCHEMA.md))
411
411
  - `pystata`-based runner with native-typed `r()`, `e()`, and matrices
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "stata-code"
7
- version = "0.7.0"
7
+ version = "0.7.1"
8
8
  description = "Agent-native Stata bridge — one core, multiple frontends (MCP, Jupyter, VSCode)"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -174,7 +174,7 @@ def is_available() -> bool:
174
174
  return True
175
175
 
176
176
 
177
- __version__ = "0.7.0"
177
+ __version__ = "0.7.1"
178
178
 
179
179
  __all__ = [
180
180
  # Primary entry points
@@ -1195,11 +1195,20 @@ def _extract_warnings(log: str) -> list: # list[StataWarning]
1195
1195
 
1196
1196
 
1197
1197
  _GRAPH_NAME_RE = re.compile(r"\bname\(\s*([A-Za-z_][A-Za-z0-9_]*)", re.IGNORECASE)
1198
+ # Stata's default in-memory graph name, (re)used by any graph command that
1199
+ # omits an explicit `name(...)` option. Capture/redraw detection keys off this.
1200
+ _DEFAULT_GRAPH_NAME = "Graph"
1201
+ # Commands that actually *draw* a graph (and thus create/overwrite an
1202
+ # in-memory graph). Deliberately excludes the `graph` utility subcommands
1203
+ # (export, display, dir, drop, describe, save, use, rename, set, copy, query,
1204
+ # replay) — those operate on existing graphs and must not be mistaken for a
1205
+ # redraw, or a bare `graph export` cell would spuriously re-surface a stale
1206
+ # graph.
1198
1207
  _GRAPH_COMMAND_RE = re.compile(
1199
1208
  r"^\s*(?:"
1200
- r"graph\s+\w+|"
1201
- r"twoway|scatter|line|connected|histogram|kdensity|lowess|lfit|qfit|"
1202
- r"coefplot|binscatter"
1209
+ r"graph\s+(?:bar|hbar|box|hbox|dot|pie|twoway|matrix|combine)\b|"
1210
+ r"twoway|scatter|line|connected|histogram|hist|kdensity|lpoly|lowess|"
1211
+ r"lfit|qfit|coefplot|binscatter|marginsplot"
1203
1212
  r")\b",
1204
1213
  re.IGNORECASE,
1205
1214
  )
@@ -1262,19 +1271,40 @@ def _collect_graphs(
1262
1271
  source_hints: dict[str, tuple[str, int]] | None = None,
1263
1272
  unnamed_source_hints: list[tuple[str, int]] | None = None,
1264
1273
  ) -> list[GraphInfo]:
1265
- """Capture graphs that user code newly created.
1274
+ """Capture graphs that user code newly created or redrew.
1266
1275
 
1267
1276
  Strategy: snapshot graph names before user code (`pre_existing`), call
1268
- after to find the post-existing list, take the set difference. For each
1269
- new graph: `graph display <name>` (makes it active), `graph export` to a
1270
- tmpfile, read bytes, store under a ref. Tmpfile is deleted after.
1277
+ after to find the post-existing list. Capture a graph when its name is
1278
+ genuinely new *or* when this cell's source shows it (re)drew that name.
1279
+
1280
+ The redraw case matters because Stata keeps only one in-memory graph per
1281
+ name, so a command that overwrites an existing name (most commonly the
1282
+ default ``Graph``, produced by any unnamed graph command) leaves the
1283
+ ``graph dir`` name set unchanged. A pure set-difference against
1284
+ `pre_existing` therefore misses it — which is why, in a persistent session
1285
+ (Jupyter cell 2+, repeated MCP runs), only the first graph ever surfaced.
1286
+
1287
+ For each captured graph: `graph display <name>` (makes it active),
1288
+ `graph export` to a tmpfile, read bytes, store under a ref. Tmpfile is
1289
+ deleted after.
1271
1290
  """
1272
1291
  after_names = _list_graph_names(rt)
1273
- new_names = [n for n in after_names if n not in pre_existing]
1274
- if not new_names:
1275
- return []
1276
1292
  source_hints = source_hints or {}
1277
1293
  unnamed_source_hints = unnamed_source_hints or []
1294
+
1295
+ # Names this cell explicitly drew, inferred from its source: every
1296
+ # `name(...)` option, plus the default graph when any unnamed graph
1297
+ # command ran. These are re-captured even if they already existed, so an
1298
+ # in-place redraw is not dropped.
1299
+ redrawn = set(source_hints)
1300
+ if unnamed_source_hints:
1301
+ redrawn.add(_DEFAULT_GRAPH_NAME)
1302
+
1303
+ new_names = [
1304
+ n for n in after_names if n not in pre_existing or n in redrawn
1305
+ ]
1306
+ if not new_names:
1307
+ return []
1278
1308
  unattributed_names = [n for n in new_names if n not in source_hints]
1279
1309
  unnamed_by_graph: dict[str, tuple[str, int]] = {}
1280
1310
  if len(unattributed_names) == len(unnamed_source_hints):
@@ -102,6 +102,35 @@ def _word_at_cursor(code: str, cursor_pos: int) -> tuple[str, int, int]:
102
102
  return code[start:end], start, end
103
103
 
104
104
 
105
+ def _strip_command_echo(log_text: str) -> str:
106
+ """Drop Stata's do-file command echo from a captured cell log.
107
+
108
+ pystata runs a multi-line cell as a temporary do-file, and Stata echoes
109
+ every submitted command — ``. cmd`` for the first line of each command and
110
+ ``> ...`` for wrapped/continued lines — regardless of the ``echo=False``
111
+ flag (which only suppresses echo for a single inline command). In a
112
+ notebook the input cell already shows the source, so the echo is pure
113
+ duplication; for a cell with no textual output (e.g. a graph) the echo is
114
+ the *only* thing shown, which reads as a useless repeat of the code.
115
+
116
+ Strip the echoed command/continuation lines, keep genuine command output,
117
+ and collapse the blank-line runs the removal leaves behind. Echoed lines
118
+ always start at column 0 with ``. `` (dot-space) or ``> `` (continuation);
119
+ real Stata output never begins that way, so this is safe.
120
+ """
121
+ kept: list[str] = []
122
+ for line in log_text.split("\n"):
123
+ if line.startswith(". ") or line.startswith("> "):
124
+ continue
125
+ # Collapse leading and consecutive blank lines left by removed echoes.
126
+ if not line.strip() and (not kept or not kept[-1].strip()):
127
+ continue
128
+ kept.append(line)
129
+ while kept and not kept[-1].strip():
130
+ kept.pop()
131
+ return "\n".join(kept)
132
+
133
+
105
134
  # ─────────────────────────────────────────────────────────────────────────────
106
135
  # Kernel
107
136
  # ─────────────────────────────────────────────────────────────────────────────
@@ -155,8 +184,9 @@ class StataKernel(_KernelBase):
155
184
  self._last_result = result
156
185
 
157
186
  if not silent:
158
- if result.log.head:
159
- self._stream("stdout", result.log.head + "\n")
187
+ log_text = _strip_command_echo(result.log.head) if result.log.head else ""
188
+ if log_text:
189
+ self._stream("stdout", log_text + "\n")
160
190
  if result.warnings:
161
191
  for w in result.warnings:
162
192
  self._stream("stderr", f"[{w.kind}] {w.message}\n")
@@ -95,7 +95,7 @@ from stata_code.core.runner import (
95
95
  )
96
96
  from stata_code.core.schema import RunResult
97
97
 
98
- __version__ = "0.7.0"
98
+ __version__ = "0.7.1"
99
99
 
100
100
  SERVER_INSTRUCTIONS = (
101
101
  "Use stata-code for running and inspecting Stata code. Prefer structuredContent "
@@ -130,6 +130,73 @@ class TestStataKernelClass:
130
130
  finally:
131
131
  kernel_module._HAS_IPYKERNEL = original
132
132
 
133
+ def test_do_execute_suppresses_pure_command_echo(self):
134
+ """A cell with no textual output (e.g. a graph) must not stream the
135
+ echoed source back — that read as a useless repeat of the code."""
136
+ from stata_code.kernel import kernel as kernel_module
137
+
138
+ original = kernel_module._HAS_IPYKERNEL
139
+ kernel_module._HAS_IPYKERNEL = True
140
+ echo_only = LogInfo(
141
+ head='\n. * 3) fit\n. twoway (scatter price mpg) (lfit price mpg)\n\n. \n',
142
+ tail="",
143
+ lines_total=5,
144
+ bytes_total=60,
145
+ )
146
+ mock_result = _make_run_result(ok=True, log=echo_only)
147
+ try:
148
+ from stata_code.kernel import StataKernel
149
+
150
+ kb = StataKernel()
151
+ streamed: list[tuple[str, str]] = []
152
+ with patch.object(
153
+ kb, "_stream", side_effect=lambda n, t: streamed.append((n, t))
154
+ ):
155
+ with patch(
156
+ "stata_code.kernel.kernel.execute", return_value=mock_result
157
+ ):
158
+ reply = kb.do_execute(
159
+ "* 3) fit\ntwoway (scatter price mpg) (lfit price mpg)",
160
+ silent=False,
161
+ )
162
+ assert reply["status"] == "ok"
163
+ assert not any(name == "stdout" for name, _ in streamed)
164
+ finally:
165
+ kernel_module._HAS_IPYKERNEL = original
166
+
167
+ def test_do_execute_streams_output_without_echo(self):
168
+ """Genuine command output is streamed, but the leading `. cmd` echo is
169
+ stripped from it."""
170
+ from stata_code.kernel import kernel as kernel_module
171
+
172
+ original = kernel_module._HAS_IPYKERNEL
173
+ kernel_module._HAS_IPYKERNEL = True
174
+ mixed = LogInfo(
175
+ head="\n. summarize price\n\n price | 74\n\n. \n",
176
+ tail="",
177
+ lines_total=6,
178
+ bytes_total=40,
179
+ )
180
+ mock_result = _make_run_result(ok=True, log=mixed)
181
+ try:
182
+ from stata_code.kernel import StataKernel
183
+
184
+ kb = StataKernel()
185
+ streamed: list[tuple[str, str]] = []
186
+ with patch.object(
187
+ kb, "_stream", side_effect=lambda n, t: streamed.append((n, t))
188
+ ):
189
+ with patch(
190
+ "stata_code.kernel.kernel.execute", return_value=mock_result
191
+ ):
192
+ kb.do_execute("summarize price", silent=False)
193
+ stdout = [t for n, t in streamed if n == "stdout"]
194
+ assert len(stdout) == 1
195
+ assert ". summarize price" not in stdout[0]
196
+ assert "price | 74" in stdout[0]
197
+ finally:
198
+ kernel_module._HAS_IPYKERNEL = original
199
+
133
200
  def test_do_complete_returns_stata_keywords(self):
134
201
  """do_complete should return Stata keyword matches."""
135
202
  from stata_code.kernel import StataKernel
@@ -332,6 +399,52 @@ class TestInstallKernel:
332
399
  assert flags == [False]
333
400
 
334
401
 
402
+ class TestCommandEchoStripping:
403
+ """Unit tests for `_strip_command_echo` (pure, no Stata required)."""
404
+
405
+ def test_pure_echo_graph_cell_becomes_empty(self):
406
+ from stata_code.kernel.kernel import _strip_command_echo
407
+
408
+ log = "\n. * 3) fit\n. twoway (scatter price mpg) (lfit price mpg)\n\n. \n"
409
+ assert _strip_command_echo(log) == ""
410
+
411
+ def test_wrapped_continuation_lines_stripped(self):
412
+ from stata_code.kernel.kernel import _strip_command_echo
413
+
414
+ # Stata wraps a long command onto a `> ` continuation line.
415
+ log = (
416
+ '\n. twoway scatter price mpg, title("A long title that wraps\n'
417
+ '> onto another line")\n\n. \n'
418
+ )
419
+ assert _strip_command_echo(log) == ""
420
+
421
+ def test_real_output_preserved_echo_removed(self):
422
+ from stata_code.kernel.kernel import _strip_command_echo
423
+
424
+ log = "\n. summarize price\n\n price | 74\n\n. \n"
425
+ out = _strip_command_echo(log)
426
+ assert ". summarize" not in out
427
+ assert out == " price | 74"
428
+
429
+ def test_consecutive_blank_lines_collapsed(self):
430
+ from stata_code.kernel.kernel import _strip_command_echo
431
+
432
+ assert _strip_command_echo("line1\n\n\n\nline2") == "line1\n\nline2"
433
+
434
+ def test_log_without_echo_unchanged(self):
435
+ from stata_code.kernel.kernel import _strip_command_echo
436
+
437
+ log = " Variable | Obs\n price | 74"
438
+ assert _strip_command_echo(log) == log
439
+
440
+ def test_missing_value_dot_output_not_stripped(self):
441
+ from stata_code.kernel.kernel import _strip_command_echo
442
+
443
+ # A bare "." (e.g. `display .`) is real output, not an echoed prompt
444
+ # (which is always ". " — dot-space). It must survive.
445
+ assert _strip_command_echo(".") == "."
446
+
447
+
335
448
  # NOTE: TestStataGraphDataUri was removed in v0.2. The legacy `StataGraph`
336
449
  # dataclass (with .to_base64() / .to_data_uri()) is gone; the v1.0 `GraphInfo`
337
450
  # schema returns refs by default and inline base64 only when explicitly
@@ -265,6 +265,67 @@ class TestGraphCapture:
265
265
  # refs are unique per index
266
266
  assert len({g.ref for g in r.graphs}) == 2
267
267
 
268
+ def test_redrawn_default_graph_recaptured_each_cell(self, loaded_auto):
269
+ """Regression: in a persistent session every unnamed graph command
270
+ overwrites the default "Graph" in place, so a pure name set-diff sees
271
+ no new name on the 2nd+ run and captured nothing — only the first
272
+ Jupyter cell's graph displayed. Each redraw must now surface its own
273
+ graph. NOTE: deliberately no _clean_graphs between runs."""
274
+ from stata_code.core.runner import execute
275
+
276
+ self._clean_graphs(execute)
277
+ r1 = execute("scatter price mpg")
278
+ assert len(r1.graphs) == 1
279
+ r2 = execute("scatter weight mpg")
280
+ assert len(r2.graphs) == 1 # was 0 before the fix
281
+ r3 = execute("histogram price")
282
+ assert len(r3.graphs) == 1
283
+
284
+ def test_redrawn_named_graph_recaptured(self, loaded_auto):
285
+ """A named graph redrawn under the same name across runs is captured
286
+ every time, not just the first."""
287
+ from stata_code.core.runner import execute
288
+
289
+ self._clean_graphs(execute)
290
+ r1 = execute("scatter price mpg, name(g_redraw, replace)")
291
+ assert len(r1.graphs) == 1
292
+ r2 = execute("scatter weight mpg, name(g_redraw, replace)")
293
+ assert len(r2.graphs) == 1 # was 0 before the fix
294
+ assert r2.graphs[0].name == "g_redraw"
295
+
296
+ def test_no_graph_cell_after_graph_no_recapture(self, loaded_auto):
297
+ """A cell that draws nothing must not re-surface an existing graph
298
+ left over from an earlier cell."""
299
+ from stata_code.core.runner import execute
300
+
301
+ self._clean_graphs(execute)
302
+ first = execute("scatter price mpg")
303
+ assert len(first.graphs) == 1
304
+ r = execute('display "just text, no graph"')
305
+ assert r.graphs == []
306
+
307
+ def test_graph_export_cell_does_not_recapture(self, loaded_auto, tmp_path):
308
+ """A utility-only cell (graph export of a prior graph) must not be
309
+ treated as a redraw — `graph export` is not a drawing command."""
310
+ from stata_code.core.runner import execute
311
+
312
+ self._clean_graphs(execute)
313
+ execute("scatter price mpg") # default Graph now exists
314
+ out = (tmp_path / "export.png").as_posix()
315
+ r = execute(f'graph export "{out}", replace')
316
+ assert r.graphs == []
317
+
318
+ def test_new_graph_with_untouched_graph_persisting(self, loaded_auto):
319
+ """When a cell draws one new named graph while an older, untouched
320
+ named graph still lives in memory, only the new one is captured."""
321
+ from stata_code.core.runner import execute
322
+
323
+ self._clean_graphs(execute)
324
+ execute("scatter price mpg, name(g_keep)")
325
+ r = execute("histogram weight, name(g_fresh)")
326
+ assert len(r.graphs) == 1
327
+ assert r.graphs[0].name == "g_fresh"
328
+
268
329
  def test_get_graph_returns_bytes(self, loaded_auto):
269
330
  from stata_code.core.runner import execute, get_graph
270
331
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes