mcp-stata 1.13.0__py3-none-any.whl → 1.16.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of mcp-stata might be problematic. Click here for more details.

mcp_stata/stata_client.py CHANGED
@@ -1,4 +1,5 @@
1
1
  import asyncio
2
+ import io
2
3
  import inspect
3
4
  import json
4
5
  import logging
@@ -11,7 +12,7 @@ import tempfile
11
12
  import threading
12
13
  import time
13
14
  import uuid
14
- from contextlib import contextmanager
15
+ from contextlib import contextmanager, redirect_stdout, redirect_stderr
15
16
  from importlib.metadata import PackageNotFoundError, version
16
17
  from io import StringIO
17
18
  from typing import Any, Awaitable, Callable, Dict, Generator, List, Optional, Tuple
@@ -158,12 +159,28 @@ class StataClient:
158
159
  MAX_CACHE_BYTES = 500 * 1024 * 1024 # Maximum cache size in bytes (~500MB)
159
160
  LIST_GRAPHS_TTL = 0.075 # TTL for list_graphs cache (75ms)
160
161
 
162
+ def __init__(self):
163
+ self._exec_lock = threading.RLock()
164
+ self._is_executing = False
165
+ self._command_idx = 0 # Counter for user-initiated commands
166
+ self._initialized = False
167
+ from .graph_detector import GraphCreationDetector
168
+ self._graph_detector = GraphCreationDetector(self)
169
+
161
170
  def __new__(cls):
162
171
  inst = super(StataClient, cls).__new__(cls)
163
172
  inst._exec_lock = threading.RLock()
164
173
  inst._is_executing = False
174
+ inst._command_idx = 0
175
+ from .graph_detector import GraphCreationDetector
176
+ inst._graph_detector = GraphCreationDetector(inst)
165
177
  return inst
166
178
 
179
+ def _increment_command_idx(self) -> int:
180
+ """Increment and return the command counter."""
181
+ self._command_idx += 1
182
+ return self._command_idx
183
+
167
184
  @contextmanager
168
185
  def _redirect_io(self, out_buf, err_buf):
169
186
  """Safely redirect stdout/stderr for the duration of a Stata call."""
@@ -204,9 +221,16 @@ class StataClient:
204
221
  except Exception:
205
222
  pass
206
223
 
207
- def _create_smcl_log_path(self, *, prefix: str = "mcp_smcl_", max_hex: Optional[int] = None) -> str:
224
+ def _create_smcl_log_path(
225
+ self,
226
+ *,
227
+ prefix: str = "mcp_smcl_",
228
+ max_hex: Optional[int] = None,
229
+ base_dir: Optional[str] = None,
230
+ ) -> str:
208
231
  hex_id = uuid.uuid4().hex if max_hex is None else uuid.uuid4().hex[:max_hex]
209
- smcl_path = os.path.join(tempfile.gettempdir(), f"{prefix}{hex_id}.smcl")
232
+ base = os.path.realpath(tempfile.gettempdir())
233
+ smcl_path = os.path.join(base, f"{prefix}{hex_id}.smcl")
210
234
  self._safe_unlink(smcl_path)
211
235
  return smcl_path
212
236
 
@@ -215,19 +239,110 @@ class StataClient:
215
239
  return f"_mcp_smcl_{uuid.uuid4().hex[:8]}"
216
240
 
217
241
  def _open_smcl_log(self, smcl_path: str, log_name: str, *, quiet: bool = False) -> bool:
218
- cmd = f"{'quietly ' if quiet else ''}log using \"{smcl_path}\", replace smcl name({log_name})"
242
+ path_for_stata = smcl_path.replace("\\", "/")
243
+ base_cmd = f"log using \"{path_for_stata}\", replace smcl name({log_name})"
244
+ unnamed_cmd = f"log using \"{path_for_stata}\", replace smcl"
219
245
  for attempt in range(4):
220
246
  try:
221
- self.stata.run(cmd, echo=False)
222
- return True
223
- except Exception:
247
+ logger.debug(
248
+ "_open_smcl_log attempt=%s log_name=%s path=%s",
249
+ attempt + 1,
250
+ log_name,
251
+ smcl_path,
252
+ )
253
+ logger.warning(
254
+ "SMCL open attempt %s cwd=%s path=%s",
255
+ attempt + 1,
256
+ os.getcwd(),
257
+ smcl_path,
258
+ )
259
+ logger.debug(
260
+ "SMCL open attempt=%s cwd=%s path=%s cmd=%s",
261
+ attempt + 1,
262
+ os.getcwd(),
263
+ smcl_path,
264
+ base_cmd,
265
+ )
266
+ try:
267
+ close_ret = self.stata.run("capture log close _all", echo=False)
268
+ if close_ret:
269
+ logger.warning("SMCL close_all output: %s", close_ret)
270
+ except Exception:
271
+ pass
272
+ cmd = f"{'quietly ' if quiet else ''}{base_cmd}"
273
+ try:
274
+ output_buf = StringIO()
275
+ with redirect_stdout(output_buf), redirect_stderr(output_buf):
276
+ self.stata.run(cmd, echo=False)
277
+ ret = output_buf.getvalue().strip()
278
+ if ret:
279
+ logger.warning("SMCL log open output: %s", ret)
280
+ except Exception as e:
281
+ logger.warning("SMCL log open failed (attempt %s): %s", attempt + 1, e)
282
+ logger.warning("SMCL log open failed: %r", e)
283
+ try:
284
+ retry_buf = StringIO()
285
+ with redirect_stdout(retry_buf), redirect_stderr(retry_buf):
286
+ self.stata.run(base_cmd, echo=False)
287
+ ret = retry_buf.getvalue().strip()
288
+ if ret:
289
+ logger.warning("SMCL log open output (no quiet): %s", ret)
290
+ except Exception as inner:
291
+ logger.warning("SMCL log open retry failed: %s", inner)
292
+ query_buf = StringIO()
293
+ try:
294
+ with redirect_stdout(query_buf), redirect_stderr(query_buf):
295
+ self.stata.run("log query", echo=False)
296
+ except Exception as query_err:
297
+ query_buf.write(f"log query failed: {query_err!r}")
298
+ query_ret = query_buf.getvalue().strip()
299
+ logger.warning("SMCL log query output: %s", query_ret)
300
+
301
+ if query_ret:
302
+ query_lower = query_ret.lower()
303
+ log_confirmed = "log:" in query_lower and "smcl" in query_lower and " on" in query_lower
304
+ if log_confirmed:
305
+ self._last_smcl_log_named = True
306
+ logger.info("SMCL log confirmed: %s", path_for_stata)
307
+ return True
308
+ logger.warning("SMCL log not confirmed after open; query_ret=%s", query_ret)
309
+ try:
310
+ unnamed_output = StringIO()
311
+ with redirect_stdout(unnamed_output), redirect_stderr(unnamed_output):
312
+ self.stata.run(unnamed_cmd, echo=False)
313
+ unnamed_ret = unnamed_output.getvalue().strip()
314
+ if unnamed_ret:
315
+ logger.warning("SMCL log open output (unnamed): %s", unnamed_ret)
316
+ except Exception as e:
317
+ logger.warning("SMCL log open failed (unnamed, attempt %s): %s", attempt + 1, e)
318
+ unnamed_query_buf = StringIO()
319
+ try:
320
+ with redirect_stdout(unnamed_query_buf), redirect_stderr(unnamed_query_buf):
321
+ self.stata.run("log query", echo=False)
322
+ except Exception as query_err:
323
+ unnamed_query_buf.write(f"log query failed: {query_err!r}")
324
+ unnamed_query = unnamed_query_buf.getvalue().strip()
325
+ if unnamed_query:
326
+ unnamed_lower = unnamed_query.lower()
327
+ unnamed_confirmed = "log:" in unnamed_lower and "smcl" in unnamed_lower and " on" in unnamed_lower
328
+ if unnamed_confirmed:
329
+ self._last_smcl_log_named = False
330
+ logger.info("SMCL log confirmed (unnamed): %s", path_for_stata)
331
+ return True
332
+ except Exception as e:
333
+ logger.warning("Failed to open SMCL log (attempt %s): %s", attempt + 1, e)
224
334
  if attempt < 3:
225
335
  time.sleep(0.1)
336
+ logger.warning("Failed to open SMCL log with cmd: %s", cmd)
226
337
  return False
227
338
 
228
339
  def _close_smcl_log(self, log_name: str) -> None:
229
340
  try:
230
- self.stata.run(f"capture log close {log_name}", echo=False)
341
+ use_named = getattr(self, "_last_smcl_log_named", None)
342
+ if use_named is False:
343
+ self.stata.run("capture log close", echo=False)
344
+ else:
345
+ self.stata.run(f"capture log close {log_name}", echo=False)
231
346
  except Exception:
232
347
  pass
233
348
 
@@ -281,6 +396,9 @@ class StataClient:
281
396
  ) -> Optional[dict[str, str]]:
282
397
  # Capture initial graph state BEFORE execution starts
283
398
  if graph_cache:
399
+ # Clear detection state for the new command (detected/removed sets)
400
+ # but preserve _last_graph_state signatures for modification detection.
401
+ graph_cache.detector.clear_detection_state()
284
402
  try:
285
403
  graph_cache._initial_graphs = set(self.list_graphs(force_refresh=True))
286
404
  logger.debug(f"Initial graph state captured: {graph_cache._initial_graphs}")
@@ -312,14 +430,21 @@ class StataClient:
312
430
  return
313
431
  try:
314
432
  cached_graphs = []
315
- initial_graphs = getattr(graph_cache, "_initial_graphs", set())
316
- current_graphs = set(self.list_graphs(force_refresh=True))
317
- new_graphs = current_graphs - initial_graphs - graph_cache._cached_graphs
318
-
319
- if new_graphs:
320
- logger.info(f"Detected {len(new_graphs)} new graph(s): {sorted(new_graphs)}")
433
+ # Use detector to find new OR modified graphs
434
+ pystata_detected = await anyio.to_thread.run_sync(graph_cache.detector._detect_graphs_via_pystata)
435
+
436
+ # Combine with any pending graphs in queue
437
+ with graph_cache._lock:
438
+ to_process = set(pystata_detected) | set(graph_cache._graphs_to_cache)
439
+ graph_cache._graphs_to_cache.clear()
440
+
441
+ if to_process:
442
+ logger.info(f"Detected {len(to_process)} new or modified graph(s): {sorted(to_process)}")
321
443
 
322
- for graph_name in new_graphs:
444
+ for graph_name in to_process:
445
+ if graph_name in graph_cache._cached_graphs:
446
+ continue
447
+
323
448
  try:
324
449
  cache_result = await anyio.to_thread.run_sync(
325
450
  self.cache_graph_on_creation,
@@ -379,7 +504,8 @@ class StataClient:
379
504
  on_chunk: Optional[Callable[[str], Awaitable[None]]] = None,
380
505
  ) -> None:
381
506
  last_pos = 0
382
- # Wait for Stata to create the SMCL file (placeholder removed to avoid locks)
507
+ emitted_debug_chunks = 0
508
+ # Wait for Stata to create the SMCL file
383
509
  while not done.is_set() and not os.path.exists(smcl_path):
384
510
  await anyio.sleep(0.05)
385
511
 
@@ -399,7 +525,7 @@ class StataClient:
399
525
  return ""
400
526
  except Exception:
401
527
  return ""
402
- raise
528
+ return ""
403
529
  except FileNotFoundError:
404
530
  return ""
405
531
 
@@ -407,17 +533,32 @@ class StataClient:
407
533
  chunk = await anyio.to_thread.run_sync(_read_content)
408
534
  if chunk:
409
535
  last_pos += len(chunk)
410
- await notify_log(chunk)
536
+ try:
537
+ await notify_log(chunk)
538
+ except Exception as exc:
539
+ logger.debug("notify_log failed: %s", exc)
411
540
  if on_chunk is not None:
412
- await on_chunk(chunk)
541
+ try:
542
+ await on_chunk(chunk)
543
+ except Exception as exc:
544
+ logger.debug("on_chunk callback failed: %s", exc)
413
545
  await anyio.sleep(0.05)
414
546
 
415
547
  chunk = await anyio.to_thread.run_sync(_read_content)
548
+ if on_chunk is not None:
549
+ # Final check even if last chunk is empty, to ensure
550
+ # graphs created at the very end are detected.
551
+ try:
552
+ await on_chunk(chunk or "")
553
+ except Exception as exc:
554
+ logger.debug("final on_chunk check failed: %s", exc)
555
+
416
556
  if chunk:
417
557
  last_pos += len(chunk)
418
- await notify_log(chunk)
419
- if on_chunk is not None:
420
- await on_chunk(chunk)
558
+ try:
559
+ await notify_log(chunk)
560
+ except Exception as exc:
561
+ logger.debug("notify_log failed: %s", exc)
421
562
 
422
563
  except Exception as e:
423
564
  logger.warning(f"Log streaming failed: {e}")
@@ -442,9 +583,21 @@ class StataClient:
442
583
  try:
443
584
  from sfi import Scalar, SFIToolkit # Import SFI tools
444
585
  with self._temp_cwd(cwd):
445
- log_opened = self._open_smcl_log(smcl_path, smcl_log_name)
586
+ logger.debug(
587
+ "opening SMCL log name=%s path=%s cwd=%s",
588
+ smcl_log_name,
589
+ smcl_path,
590
+ os.getcwd(),
591
+ )
592
+ try:
593
+ log_opened = self._open_smcl_log(smcl_path, smcl_log_name, quiet=True)
594
+ except Exception as e:
595
+ log_opened = False
596
+ logger.warning("_open_smcl_log raised: %r", e)
597
+ logger.info("SMCL log_opened=%s path=%s", log_opened, smcl_path)
446
598
  if require_smcl_log and not log_opened:
447
599
  exc = RuntimeError("Failed to open SMCL log")
600
+ logger.error("SMCL log open failed for %s", smcl_path)
448
601
  rc = 1
449
602
  if exc is None:
450
603
  try:
@@ -452,7 +605,10 @@ class StataClient:
452
605
  try:
453
606
  if trace:
454
607
  self.stata.run("set trace on")
608
+ logger.debug("running Stata command echo=%s: %s", echo, command)
455
609
  ret = self.stata.run(command, echo=echo)
610
+ if ret:
611
+ logger.debug("stata.run output: %s", ret)
456
612
 
457
613
  setattr(self, hold_attr, f"mcp_hold_{uuid.uuid4().hex[:8]}")
458
614
  self.stata.run(
@@ -471,6 +627,7 @@ class StataClient:
471
627
  pass
472
628
  except Exception as e:
473
629
  exc = e
630
+ logger.error("stata.run failed: %r", e)
474
631
  if rc in (-1, 0):
475
632
  rc = 1
476
633
  finally:
@@ -688,7 +845,15 @@ class StataClient:
688
845
  return None
689
846
  try:
690
847
  with self._cache_lock:
691
- return self._preemptive_cache.get(graph_name)
848
+ cache_path = self._preemptive_cache.get(graph_name)
849
+ if not cache_path:
850
+ return None
851
+
852
+ # Double-check validity (e.g. signature match for current command)
853
+ if not self._is_cache_valid(graph_name, cache_path):
854
+ return None
855
+
856
+ return cache_path
692
857
  except Exception:
693
858
  return None
694
859
 
@@ -743,13 +908,16 @@ class StataClient:
743
908
  graph_ready_format: str,
744
909
  graph_ready_initial: Optional[dict[str, str]],
745
910
  last_check: List[float],
911
+ force: bool = False,
746
912
  ) -> None:
747
913
  if not graph_cache or not graph_cache.auto_cache:
748
914
  return
749
- if self._is_executing:
915
+ if self._is_executing and not force:
916
+ # Skip polling if Stata is busy; it will block on _exec_lock anyway.
917
+ # During final check (force=True), we know it's safe because _run_streaming_blocking has finished.
750
918
  return
751
919
  now = time.monotonic()
752
- if last_check and now - last_check[0] < 0.25:
920
+ if not force and last_check and now - last_check[0] < 0.25:
753
921
  return
754
922
  if last_check:
755
923
  last_check[0] = now
@@ -789,9 +957,14 @@ class StataClient:
789
957
  if previous is not None and previous == signature:
790
958
  continue
791
959
  try:
792
- export_path = await anyio.to_thread.run_sync(
793
- lambda: self.export_graph(graph_name, format=export_format)
794
- )
960
+ export_path = None
961
+ if export_format == "svg":
962
+ export_path = self._get_cached_graph_path(graph_name)
963
+
964
+ if not export_path:
965
+ export_path = await anyio.to_thread.run_sync(
966
+ lambda: self.export_graph(graph_name, format=export_format)
967
+ )
795
968
  payload = {
796
969
  "event": "graph_ready",
797
970
  "task_id": task_id,
@@ -807,17 +980,18 @@ class StataClient:
807
980
  logger.warning("graph_ready export failed for %s: %s", graph_name, e)
808
981
 
809
982
  def _get_graph_signature(self, graph_name: str) -> str:
983
+ """
984
+ Get a stable signature for a graph without calling Stata.
985
+ Consistent with GraphCreationDetector implementation.
986
+ """
810
987
  if not graph_name:
811
988
  return ""
812
- try:
813
- response = self.exec_lightweight(f"graph describe {graph_name}")
814
- if response.success and response.stdout:
815
- return response.stdout
816
- if response.stderr:
817
- return response.stderr
818
- except Exception:
819
- return ""
820
- return ""
989
+ cmd_idx = getattr(self, "_command_idx", 0)
990
+ # Only include command index for default 'Graph' to detect modifications.
991
+ # For named graphs, we only want to detect them when they are new or renamed.
992
+ if graph_name.lower() == "graph":
993
+ return f"{graph_name}_{cmd_idx}"
994
+ return graph_name
821
995
 
822
996
  def _request_break_in(self) -> None:
823
997
  """
@@ -924,6 +1098,10 @@ class StataClient:
924
1098
 
925
1099
  # Get discovered Stata paths (cached from first call)
926
1100
  discovery_candidates = _get_discovery_candidates()
1101
+ if not discovery_candidates:
1102
+ raise RuntimeError("No Stata candidates found during discovery")
1103
+
1104
+ logger.info("Initializing Stata engine (attempting up to %d candidate binaries)...", len(discovery_candidates))
927
1105
 
928
1106
  # Diagnostic: force faulthandler to output to stderr for C crashes
929
1107
  import faulthandler
@@ -952,13 +1130,16 @@ class StataClient:
952
1130
  curr = parent
953
1131
 
954
1132
  ordered_candidates = []
955
- if bin_dir:
956
- ordered_candidates.append(bin_dir)
957
1133
  if app_bundle:
958
- ordered_candidates.append(app_bundle)
1134
+ # On macOS, the parent of the .app is often the correct install path
1135
+ # (e.g., /Applications/StataNow containing StataMP.app)
959
1136
  parent_dir = os.path.dirname(app_bundle)
960
- if parent_dir not in ordered_candidates:
1137
+ if parent_dir and parent_dir != "/":
961
1138
  ordered_candidates.append(parent_dir)
1139
+ ordered_candidates.append(app_bundle)
1140
+
1141
+ if bin_dir:
1142
+ ordered_candidates.append(bin_dir)
962
1143
 
963
1144
  # Deduplicate preserving order
964
1145
  seen = set()
@@ -982,7 +1163,8 @@ with redirect_stdout(sys.stderr), redirect_stderr(sys.stderr):
982
1163
  try:
983
1164
  stata_setup.config({repr(path)}, {repr(edition)})
984
1165
  from pystata import stata
985
- stata.run('about', echo=True)
1166
+ # Minimal verification of engine health
1167
+ stata.run('display 1', echo=False)
986
1168
  print('PREFLIGHT_OK')
987
1169
  except Exception as e:
988
1170
  print(f'PREFLIGHT_FAIL: {{e}}', file=sys.stderr)
@@ -990,9 +1172,11 @@ with redirect_stdout(sys.stderr), redirect_stderr(sys.stderr):
990
1172
  """
991
1173
 
992
1174
  try:
1175
+ # Use shorter timeout for pre-flight if feasible,
1176
+ # but keep it safe for slow environments. 15s is usually enough for a ping.
993
1177
  res = subprocess.run(
994
1178
  [sys.executable, "-c", preflight_code],
995
- capture_output=True, text=True, timeout=30
1179
+ capture_output=True, text=True, timeout=20
996
1180
  )
997
1181
  if res.returncode != 0:
998
1182
  sys.stderr.write(f"[mcp_stata] Pre-flight failed (rc={res.returncode}) for '{path}'\n")
@@ -1435,6 +1619,7 @@ with redirect_stdout(sys.stderr), redirect_stderr(sys.stderr):
1435
1619
  if not self._initialized:
1436
1620
  self.init()
1437
1621
 
1622
+ self._increment_command_idx()
1438
1623
  # Rewrite graph names with special characters to internal aliases
1439
1624
  code = self._maybe_rewrite_graph_name_in_command(code)
1440
1625
 
@@ -1452,7 +1637,7 @@ with redirect_stdout(sys.stderr), redirect_stderr(sys.stderr):
1452
1637
  with self._temp_cwd(cwd):
1453
1638
  # Create SMCL log for authoritative output capture
1454
1639
  # Use shorter unique path to avoid Windows path issues
1455
- smcl_path = self._create_smcl_log_path(prefix="mcp_", max_hex=16)
1640
+ smcl_path = self._create_smcl_log_path(prefix="mcp_", max_hex=16, base_dir=cwd)
1456
1641
  log_name = self._make_smcl_log_name()
1457
1642
  self._open_smcl_log(smcl_path, log_name)
1458
1643
 
@@ -1598,6 +1783,57 @@ with redirect_stdout(sys.stderr), redirect_stderr(sys.stderr):
1598
1783
  error=error,
1599
1784
  )
1600
1785
 
1786
+ def _exec_no_capture_silent(self, code: str, echo: bool = False, trace: bool = False) -> CommandResponse:
1787
+ """Execute Stata code while suppressing stdout/stderr output."""
1788
+ if not self._initialized:
1789
+ self.init()
1790
+
1791
+ exc: Optional[Exception] = None
1792
+ ret_text: Optional[str] = None
1793
+ rc = 0
1794
+
1795
+ with self._exec_lock:
1796
+ try:
1797
+ from sfi import Scalar # Import SFI tools
1798
+ if trace:
1799
+ self.stata.run("set trace on")
1800
+ output_buf = StringIO()
1801
+ with redirect_stdout(output_buf), redirect_stderr(output_buf):
1802
+ ret = self.stata.run(code, echo=echo)
1803
+ if isinstance(ret, str) and ret:
1804
+ ret_text = ret
1805
+ except Exception as e:
1806
+ exc = e
1807
+ rc = 1
1808
+ finally:
1809
+ if trace:
1810
+ try:
1811
+ self.stata.run("set trace off")
1812
+ except Exception as e:
1813
+ logger.warning("Failed to turn off Stata trace mode: %s", e)
1814
+
1815
+ stdout = ""
1816
+ stderr = ""
1817
+ success = rc == 0 and exc is None
1818
+ error = None
1819
+ if not success:
1820
+ msg = str(exc) if exc else f"Stata error r({rc})"
1821
+ error = ErrorEnvelope(
1822
+ message=msg,
1823
+ rc=rc,
1824
+ command=code,
1825
+ stdout=ret_text,
1826
+ )
1827
+
1828
+ return CommandResponse(
1829
+ command=code,
1830
+ rc=rc,
1831
+ stdout=stdout,
1832
+ stderr=None,
1833
+ success=success,
1834
+ error=error,
1835
+ )
1836
+
1601
1837
  def exec_lightweight(self, code: str) -> CommandResponse:
1602
1838
  """
1603
1839
  Executes a command using simple stdout redirection (no SMCL logs).
@@ -1682,7 +1918,7 @@ with redirect_stdout(sys.stderr), redirect_stderr(sys.stderr):
1682
1918
  _log_file, log_path, tail, tee = self._create_streaming_log(trace=trace)
1683
1919
 
1684
1920
  # Create SMCL log path for authoritative output capture
1685
- smcl_path = self._create_smcl_log_path()
1921
+ smcl_path = self._create_smcl_log_path(base_dir=cwd)
1686
1922
  smcl_log_name = self._make_smcl_log_name()
1687
1923
 
1688
1924
  # Inform the MCP client immediately where to read/tail the output.
@@ -1693,80 +1929,94 @@ with redirect_stdout(sys.stderr), redirect_stderr(sys.stderr):
1693
1929
  command = f'{path_for_stata}'
1694
1930
 
1695
1931
  graph_ready_initial = self._capture_graph_state(graph_cache, emit_graph_ready)
1932
+
1933
+ # Increment AFTER capture so detected modifications are based on state BEFORE this command
1934
+ self._increment_command_idx()
1935
+
1696
1936
  graph_poll_state = [0.0]
1697
1937
 
1698
1938
  async def on_chunk_for_graphs(_chunk: str) -> None:
1699
- await self._maybe_cache_graphs_on_chunk(
1700
- graph_cache=graph_cache,
1701
- emit_graph_ready=emit_graph_ready,
1702
- notify_log=notify_log,
1703
- graph_ready_task_id=graph_ready_task_id,
1704
- graph_ready_format=graph_ready_format,
1705
- graph_ready_initial=graph_ready_initial,
1706
- last_check=graph_poll_state,
1707
- )
1708
-
1709
- done = anyio.Event()
1710
-
1711
- async with anyio.create_task_group() as tg:
1712
- async def stream_smcl() -> None:
1713
- await self._stream_smcl_log(
1714
- smcl_path=smcl_path,
1939
+ # Background the graph check so we don't block SMCL streaming or task completion
1940
+ asyncio.create_task(
1941
+ self._maybe_cache_graphs_on_chunk(
1942
+ graph_cache=graph_cache,
1943
+ emit_graph_ready=emit_graph_ready,
1715
1944
  notify_log=notify_log,
1716
- done=done,
1717
- on_chunk=on_chunk_for_graphs if graph_cache else None,
1945
+ graph_ready_task_id=graph_ready_task_id,
1946
+ graph_ready_format=graph_ready_format,
1947
+ graph_ready_initial=graph_ready_initial,
1948
+ last_check=graph_poll_state,
1718
1949
  )
1950
+ )
1719
1951
 
1720
- tg.start_soon(stream_smcl)
1952
+ done = anyio.Event()
1721
1953
 
1722
- if notify_progress is not None:
1723
- if total_lines > 0:
1724
- await notify_progress(0, float(total_lines), f"Executing command: 0/{total_lines}")
1725
- else:
1726
- await notify_progress(0, None, "Running command")
1954
+ try:
1955
+ async with anyio.create_task_group() as tg:
1956
+ async def stream_smcl() -> None:
1957
+ try:
1958
+ await self._stream_smcl_log(
1959
+ smcl_path=smcl_path,
1960
+ notify_log=notify_log,
1961
+ done=done,
1962
+ on_chunk=on_chunk_for_graphs if graph_cache else None,
1963
+ )
1964
+ except Exception as exc:
1965
+ logger.debug("SMCL streaming failed: %s", exc)
1966
+
1967
+ tg.start_soon(stream_smcl)
1968
+
1969
+ if notify_progress is not None:
1970
+ if total_lines > 0:
1971
+ await notify_progress(0, float(total_lines), f"Executing command: 0/{total_lines}")
1972
+ else:
1973
+ await notify_progress(0, None, "Running command")
1727
1974
 
1728
- try:
1729
- run_blocking = lambda: self._run_streaming_blocking(
1730
- command=command,
1731
- tee=tee,
1732
- cwd=cwd,
1733
- trace=trace,
1734
- echo=echo,
1735
- smcl_path=smcl_path,
1736
- smcl_log_name=smcl_log_name,
1737
- hold_attr="_hold_name_stream",
1738
- )
1739
1975
  try:
1740
- rc, exc = await anyio.to_thread.run_sync(
1741
- run_blocking,
1742
- abandon_on_cancel=True,
1976
+ run_blocking = lambda: self._run_streaming_blocking(
1977
+ command=command,
1978
+ tee=tee,
1979
+ cwd=cwd,
1980
+ trace=trace,
1981
+ echo=echo,
1982
+ smcl_path=smcl_path,
1983
+ smcl_log_name=smcl_log_name,
1984
+ hold_attr="_hold_name_stream",
1985
+ require_smcl_log=True,
1743
1986
  )
1744
- except TypeError:
1745
- rc, exc = await anyio.to_thread.run_sync(run_blocking)
1746
- except get_cancelled_exc_class():
1747
- self._request_break_in()
1748
- await self._wait_for_stata_stop()
1749
- raise
1750
- finally:
1751
- done.set()
1752
- tee.close()
1987
+ try:
1988
+ rc, exc = await anyio.to_thread.run_sync(
1989
+ run_blocking,
1990
+ abandon_on_cancel=True,
1991
+ )
1992
+ except TypeError:
1993
+ rc, exc = await anyio.to_thread.run_sync(run_blocking)
1994
+ except Exception as e:
1995
+ exc = e
1996
+ if rc in (-1, 0):
1997
+ rc = 1
1998
+ except get_cancelled_exc_class():
1999
+ self._request_break_in()
2000
+ await self._wait_for_stata_stop()
2001
+ raise
2002
+ finally:
2003
+ done.set()
2004
+ tee.close()
2005
+ except* Exception as exc_group:
2006
+ logger.debug("SMCL streaming task group failed: %s", exc_group)
1753
2007
 
1754
2008
  # Read SMCL content as the authoritative source
1755
2009
  smcl_content = self._read_smcl_file(smcl_path)
1756
2010
 
1757
- await self._cache_new_graphs(
1758
- graph_cache,
1759
- notify_progress=notify_progress,
1760
- total_lines=total_lines,
1761
- completed_label="Command",
1762
- )
1763
- self._emit_graph_ready_task(
1764
- emit_graph_ready=emit_graph_ready,
1765
- graph_ready_initial=graph_ready_initial,
1766
- notify_log=notify_log,
1767
- graph_ready_task_id=graph_ready_task_id,
1768
- graph_ready_format=graph_ready_format,
1769
- )
2011
+ if graph_cache:
2012
+ asyncio.create_task(
2013
+ self._cache_new_graphs(
2014
+ graph_cache,
2015
+ notify_progress=notify_progress,
2016
+ total_lines=total_lines,
2017
+ completed_label="Command",
2018
+ )
2019
+ )
1770
2020
 
1771
2021
  combined = self._build_combined_log(tail, smcl_path, rc, trace, exc)
1772
2022
 
@@ -1888,7 +2138,8 @@ with redirect_stdout(sys.stderr), redirect_stderr(sys.stderr):
1888
2138
  graph_cache = self._init_streaming_graph_cache(auto_cache_graphs, on_graph_cached, notify_log)
1889
2139
  _log_file, log_path, tail, tee = self._create_streaming_log(trace=trace)
1890
2140
 
1891
- smcl_path = self._create_smcl_log_path()
2141
+ base_dir = cwd or os.path.dirname(effective_path)
2142
+ smcl_path = self._create_smcl_log_path(base_dir=base_dir)
1892
2143
  smcl_log_name = self._make_smcl_log_name()
1893
2144
 
1894
2145
  # Inform the MCP client immediately where to read/tail the output.
@@ -1896,17 +2147,24 @@ with redirect_stdout(sys.stderr), redirect_stderr(sys.stderr):
1896
2147
 
1897
2148
  rc = -1
1898
2149
  graph_ready_initial = self._capture_graph_state(graph_cache, emit_graph_ready)
2150
+
2151
+ # Increment AFTER capture
2152
+ self._increment_command_idx()
2153
+
1899
2154
  graph_poll_state = [0.0]
1900
2155
 
1901
2156
  async def on_chunk_for_graphs(_chunk: str) -> None:
1902
- await self._maybe_cache_graphs_on_chunk(
1903
- graph_cache=graph_cache,
1904
- emit_graph_ready=emit_graph_ready,
1905
- notify_log=notify_log,
1906
- graph_ready_task_id=graph_ready_task_id,
1907
- graph_ready_format=graph_ready_format,
1908
- graph_ready_initial=graph_ready_initial,
1909
- last_check=graph_poll_state,
2157
+ # Background the graph check so we don't block SMCL streaming or task completion
2158
+ asyncio.create_task(
2159
+ self._maybe_cache_graphs_on_chunk(
2160
+ graph_cache=graph_cache,
2161
+ emit_graph_ready=emit_graph_ready,
2162
+ notify_log=notify_log,
2163
+ graph_ready_task_id=graph_ready_task_id,
2164
+ graph_ready_format=graph_ready_format,
2165
+ graph_ready_initial=graph_ready_initial,
2166
+ last_check=graph_poll_state,
2167
+ )
1910
2168
  )
1911
2169
 
1912
2170
  on_chunk_callback = on_chunk_for_progress
@@ -1917,65 +2175,72 @@ with redirect_stdout(sys.stderr), redirect_stderr(sys.stderr):
1917
2175
 
1918
2176
  done = anyio.Event()
1919
2177
 
1920
- async with anyio.create_task_group() as tg:
1921
- async def stream_smcl() -> None:
1922
- await self._stream_smcl_log(
1923
- smcl_path=smcl_path,
1924
- notify_log=notify_log,
1925
- done=done,
1926
- on_chunk=on_chunk_callback,
1927
- )
1928
-
1929
- tg.start_soon(stream_smcl)
1930
-
1931
- if notify_progress is not None:
1932
- if total_lines > 0:
1933
- await notify_progress(0, float(total_lines), f"Executing do-file: 0/{total_lines}")
1934
- else:
1935
- await notify_progress(0, None, "Running do-file")
2178
+ try:
2179
+ async with anyio.create_task_group() as tg:
2180
+ async def stream_smcl() -> None:
2181
+ try:
2182
+ await self._stream_smcl_log(
2183
+ smcl_path=smcl_path,
2184
+ notify_log=notify_log,
2185
+ done=done,
2186
+ on_chunk=on_chunk_callback,
2187
+ )
2188
+ except Exception as exc:
2189
+ logger.debug("SMCL streaming failed: %s", exc)
2190
+
2191
+ tg.start_soon(stream_smcl)
2192
+
2193
+ if notify_progress is not None:
2194
+ if total_lines > 0:
2195
+ await notify_progress(0, float(total_lines), f"Executing do-file: 0/{total_lines}")
2196
+ else:
2197
+ await notify_progress(0, None, "Running do-file")
1936
2198
 
1937
- try:
1938
- run_blocking = lambda: self._run_streaming_blocking(
1939
- command=command,
1940
- tee=tee,
1941
- cwd=cwd,
1942
- trace=trace,
1943
- echo=echo,
1944
- smcl_path=smcl_path,
1945
- smcl_log_name=smcl_log_name,
1946
- hold_attr="_hold_name_do",
1947
- )
1948
2199
  try:
1949
- rc, exc = await anyio.to_thread.run_sync(
1950
- run_blocking,
1951
- abandon_on_cancel=True,
2200
+ run_blocking = lambda: self._run_streaming_blocking(
2201
+ command=command,
2202
+ tee=tee,
2203
+ cwd=cwd,
2204
+ trace=trace,
2205
+ echo=echo,
2206
+ smcl_path=smcl_path,
2207
+ smcl_log_name=smcl_log_name,
2208
+ hold_attr="_hold_name_do",
2209
+ require_smcl_log=True,
1952
2210
  )
1953
- except TypeError:
1954
- rc, exc = await anyio.to_thread.run_sync(run_blocking)
1955
- except get_cancelled_exc_class():
1956
- self._request_break_in()
1957
- await self._wait_for_stata_stop()
1958
- raise
1959
- finally:
1960
- done.set()
1961
- tee.close()
2211
+ try:
2212
+ rc, exc = await anyio.to_thread.run_sync(
2213
+ run_blocking,
2214
+ abandon_on_cancel=True,
2215
+ )
2216
+ except TypeError:
2217
+ rc, exc = await anyio.to_thread.run_sync(run_blocking)
2218
+ except Exception as e:
2219
+ exc = e
2220
+ if rc in (-1, 0):
2221
+ rc = 1
2222
+ except get_cancelled_exc_class():
2223
+ self._request_break_in()
2224
+ await self._wait_for_stata_stop()
2225
+ raise
2226
+ finally:
2227
+ done.set()
2228
+ tee.close()
2229
+ except* Exception as exc_group:
2230
+ logger.debug("SMCL streaming task group failed: %s", exc_group)
1962
2231
 
1963
2232
  # Read SMCL content as the authoritative source
1964
2233
  smcl_content = self._read_smcl_file(smcl_path)
1965
2234
 
1966
- await self._cache_new_graphs(
1967
- graph_cache,
1968
- notify_progress=notify_progress,
1969
- total_lines=total_lines,
1970
- completed_label="Do-file",
1971
- )
1972
- self._emit_graph_ready_task(
1973
- emit_graph_ready=emit_graph_ready,
1974
- graph_ready_initial=graph_ready_initial,
1975
- notify_log=notify_log,
1976
- graph_ready_task_id=graph_ready_task_id,
1977
- graph_ready_format=graph_ready_format,
1978
- )
2235
+ if graph_cache:
2236
+ asyncio.create_task(
2237
+ self._cache_new_graphs(
2238
+ graph_cache,
2239
+ notify_progress=notify_progress,
2240
+ total_lines=total_lines,
2241
+ completed_label="Do-file",
2242
+ )
2243
+ )
1979
2244
 
1980
2245
  combined = self._build_combined_log(tail, log_path, rc, trace, exc)
1981
2246
 
@@ -2064,17 +2329,18 @@ with redirect_stdout(sys.stderr), redirect_stderr(sys.stderr):
2064
2329
  if count > self.MAX_DATA_ROWS:
2065
2330
  count = self.MAX_DATA_ROWS
2066
2331
 
2067
- try:
2068
- # Use pystata integration to retrieve data
2069
- df = self.stata.pdataframe_from_data()
2332
+ with self._exec_lock:
2333
+ try:
2334
+ # Use pystata integration to retrieve data
2335
+ df = self.stata.pdataframe_from_data()
2070
2336
 
2071
- # Slice
2072
- sliced = df.iloc[start : start + count]
2337
+ # Slice
2338
+ sliced = df.iloc[start : start + count]
2073
2339
 
2074
- # Convert to dict
2075
- return sliced.to_dict(orient="records")
2076
- except Exception as e:
2077
- return [{"error": f"Failed to retrieve data: {e}"}]
2340
+ # Convert to dict
2341
+ return sliced.to_dict(orient="records")
2342
+ except Exception as e:
2343
+ return [{"error": f"Failed to retrieve data: {e}"}]
2078
2344
 
2079
2345
  def list_variables(self) -> List[Dict[str, str]]:
2080
2346
  """Returns list of variables with labels."""
@@ -2084,17 +2350,18 @@ with redirect_stdout(sys.stderr), redirect_stderr(sys.stderr):
2084
2350
  # We can use sfi to be efficient
2085
2351
  from sfi import Data # type: ignore[import-not-found]
2086
2352
  vars_info = []
2087
- for i in range(Data.getVarCount()):
2088
- var_index = i # 0-based
2089
- name = Data.getVarName(var_index)
2090
- label = Data.getVarLabel(var_index)
2091
- type_str = Data.getVarType(var_index) # Returns int
2092
-
2093
- vars_info.append({
2094
- "name": name,
2095
- "label": label,
2096
- "type": str(type_str),
2097
- })
2353
+ with self._exec_lock:
2354
+ for i in range(Data.getVarCount()):
2355
+ var_index = i # 0-based
2356
+ name = Data.getVarName(var_index)
2357
+ label = Data.getVarLabel(var_index)
2358
+ type_str = Data.getVarType(var_index) # Returns int
2359
+
2360
+ vars_info.append({
2361
+ "name": name,
2362
+ "label": label,
2363
+ "type": str(type_str),
2364
+ })
2098
2365
  return vars_info
2099
2366
 
2100
2367
  def get_dataset_state(self) -> Dict[str, Any]:
@@ -2104,27 +2371,28 @@ with redirect_stdout(sys.stderr), redirect_stderr(sys.stderr):
2104
2371
 
2105
2372
  from sfi import Data, Macro # type: ignore[import-not-found]
2106
2373
 
2107
- n = int(Data.getObsTotal())
2108
- k = int(Data.getVarCount())
2374
+ with self._exec_lock:
2375
+ n = int(Data.getObsTotal())
2376
+ k = int(Data.getVarCount())
2109
2377
 
2110
- frame = "default"
2111
- sortlist = ""
2112
- changed = False
2113
- try:
2114
- frame = str(Macro.getGlobal("frame") or "default")
2115
- except Exception:
2116
- logger.debug("Failed to get 'frame' macro", exc_info=True)
2117
2378
  frame = "default"
2118
- try:
2119
- sortlist = str(Macro.getGlobal("sortlist") or "")
2120
- except Exception:
2121
- logger.debug("Failed to get 'sortlist' macro", exc_info=True)
2122
2379
  sortlist = ""
2123
- try:
2124
- changed = bool(int(float(Macro.getGlobal("changed") or "0")))
2125
- except Exception:
2126
- logger.debug("Failed to get 'changed' macro", exc_info=True)
2127
2380
  changed = False
2381
+ try:
2382
+ frame = str(Macro.getGlobal("frame") or "default")
2383
+ except Exception:
2384
+ logger.debug("Failed to get 'frame' macro", exc_info=True)
2385
+ frame = "default"
2386
+ try:
2387
+ sortlist = str(Macro.getGlobal("sortlist") or "")
2388
+ except Exception:
2389
+ logger.debug("Failed to get 'sortlist' macro", exc_info=True)
2390
+ sortlist = ""
2391
+ try:
2392
+ changed = bool(int(float(Macro.getGlobal("changed") or "0")))
2393
+ except Exception:
2394
+ logger.debug("Failed to get 'changed' macro", exc_info=True)
2395
+ changed = False
2128
2396
 
2129
2397
  return {"frame": frame, "n": n, "k": k, "sortlist": sortlist, "changed": changed}
2130
2398
 
@@ -2138,11 +2406,12 @@ with redirect_stdout(sys.stderr), redirect_stderr(sys.stderr):
2138
2406
  from sfi import Data # type: ignore[import-not-found]
2139
2407
 
2140
2408
  out: Dict[str, int] = {}
2141
- for i in range(int(Data.getVarCount())):
2142
- try:
2143
- out[str(Data.getVarName(i))] = i
2144
- except Exception:
2145
- continue
2409
+ with self._exec_lock:
2410
+ for i in range(int(Data.getVarCount())):
2411
+ try:
2412
+ out[str(Data.getVarName(i))] = i
2413
+ except Exception:
2414
+ continue
2146
2415
  return out
2147
2416
 
2148
2417
  def list_variables_rich(self) -> List[Dict[str, Any]]:
@@ -2549,45 +2818,46 @@ with redirect_stdout(sys.stderr), redirect_stderr(sys.stderr):
2549
2818
  return self._list_graphs_cache
2550
2819
 
2551
2820
  # Cache miss or expired, fetch fresh data
2552
- try:
2553
- # Preservation of r() results is critical because this can be called
2554
- # automatically after every user command (e.g., during streaming).
2555
- import time
2556
- hold_name = f"_mcp_ghold_{int(time.time() * 1000 % 1000000)}"
2557
- self.stata.run(f"capture _return hold {hold_name}", echo=False)
2558
-
2821
+ with self._exec_lock:
2559
2822
  try:
2560
- self.stata.run("macro define mcp_graph_list \"\"", echo=False)
2561
- self.stata.run("quietly graph dir, memory", echo=False)
2562
- from sfi import Macro # type: ignore[import-not-found]
2563
- self.stata.run("macro define mcp_graph_list `r(list)'", echo=False)
2564
- graph_list_str = Macro.getGlobal("mcp_graph_list")
2565
- finally:
2566
- self.stata.run(f"capture _return restore {hold_name}", echo=False)
2823
+ # Preservation of r() results is critical because this can be called
2824
+ # automatically after every user command (e.g., during streaming).
2825
+ import time
2826
+ hold_name = f"_mcp_ghold_{int(time.time() * 1000 % 1000000)}"
2827
+ self.stata.run(f"capture _return hold {hold_name}", echo=False)
2828
+
2829
+ try:
2830
+ self.stata.run("macro define mcp_graph_list \"\"", echo=False)
2831
+ self.stata.run("quietly graph dir, memory", echo=False)
2832
+ from sfi import Macro # type: ignore[import-not-found]
2833
+ self.stata.run("macro define mcp_graph_list `r(list)'", echo=False)
2834
+ graph_list_str = Macro.getGlobal("mcp_graph_list")
2835
+ finally:
2836
+ self.stata.run(f"capture _return restore {hold_name}", echo=False)
2567
2837
 
2568
- raw_list = graph_list_str.split() if graph_list_str else []
2838
+ raw_list = graph_list_str.split() if graph_list_str else []
2569
2839
 
2570
- # Map internal Stata names back to user-facing names when we have an alias.
2571
- reverse = getattr(self, "_graph_name_reverse", {})
2572
- graph_list = [reverse.get(n, n) for n in raw_list]
2840
+ # Map internal Stata names back to user-facing names when we have an alias.
2841
+ reverse = getattr(self, "_graph_name_reverse", {})
2842
+ graph_list = [reverse.get(n, n) for n in raw_list]
2573
2843
 
2574
- result = graph_list
2844
+ result = graph_list
2575
2845
 
2576
- # Update cache
2577
- with self._list_graphs_cache_lock:
2578
- self._list_graphs_cache = result
2579
- self._list_graphs_cache_time = time.time()
2580
-
2581
- return result
2582
-
2583
- except Exception as e:
2584
- # On error, return cached result if available, otherwise empty list
2585
- with self._list_graphs_cache_lock:
2586
- if self._list_graphs_cache is not None:
2587
- logger.warning(f"list_graphs failed, returning cached result: {e}")
2588
- return self._list_graphs_cache
2589
- logger.warning(f"list_graphs failed, no cache available: {e}")
2590
- return []
2846
+ # Update cache
2847
+ with self._list_graphs_cache_lock:
2848
+ self._list_graphs_cache = result
2849
+ self._list_graphs_cache_time = time.time()
2850
+
2851
+ return result
2852
+
2853
+ except Exception as e:
2854
+ # On error, return cached result if available, otherwise empty list
2855
+ with self._list_graphs_cache_lock:
2856
+ if self._list_graphs_cache is not None:
2857
+ logger.warning(f"list_graphs failed, returning cached result: {e}")
2858
+ return self._list_graphs_cache
2859
+ logger.warning(f"list_graphs failed, no cache available: {e}")
2860
+ return []
2591
2861
 
2592
2862
  def list_graphs_structured(self) -> GraphListResponse:
2593
2863
  names = self.list_graphs()
@@ -2614,6 +2884,7 @@ with redirect_stdout(sys.stderr), redirect_stderr(sys.stderr):
2614
2884
  if fmt not in {"pdf", "png", "svg"}:
2615
2885
  raise ValueError(f"Unsupported graph export format: {format}. Allowed: pdf, png, svg.")
2616
2886
 
2887
+
2617
2888
  if not filename:
2618
2889
  suffix = f".{fmt}"
2619
2890
  with tempfile.NamedTemporaryFile(prefix="mcp_stata_", suffix=suffix, delete=False) as tmp:
@@ -2636,9 +2907,9 @@ with redirect_stdout(sys.stderr), redirect_stderr(sys.stderr):
2636
2907
  gph_path_for_stata = gph_path.replace("\\", "/")
2637
2908
  # Make the target graph current, then save without name() (which isn't accepted there)
2638
2909
  if graph_name:
2639
- self._exec_no_capture(f'graph display "{graph_name}"', echo=False)
2640
- save_cmd = f'graph save "{gph_path_for_stata}", replace'
2641
- save_resp = self._exec_no_capture(save_cmd, echo=False)
2910
+ self._exec_no_capture_silent(f'quietly graph display "{graph_name}"', echo=False)
2911
+ save_cmd = f'quietly graph save "{gph_path_for_stata}", replace'
2912
+ save_resp = self._exec_no_capture_silent(save_cmd, echo=False)
2642
2913
  if not save_resp.success:
2643
2914
  msg = save_resp.error.message if save_resp.error else f"graph save failed (rc={save_resp.rc})"
2644
2915
  raise RuntimeError(msg)
@@ -2646,8 +2917,8 @@ with redirect_stdout(sys.stderr), redirect_stderr(sys.stderr):
2646
2917
  # 2) Prepare a do-file to export PNG externally
2647
2918
  user_filename_fwd = user_filename.replace("\\", "/")
2648
2919
  do_lines = [
2649
- f'graph use "{gph_path_for_stata}"',
2650
- f'graph export "{user_filename_fwd}", replace as(png)',
2920
+ f'quietly graph use "{gph_path_for_stata}"',
2921
+ f'quietly graph export "{user_filename_fwd}", replace as(png)',
2651
2922
  "exit",
2652
2923
  ]
2653
2924
  with tempfile.NamedTemporaryFile(prefix="mcp_stata_export_", suffix=".do", delete=False, mode="w", encoding="ascii") as do_tmp:
@@ -2698,20 +2969,21 @@ with redirect_stdout(sys.stderr), redirect_stderr(sys.stderr):
2698
2969
  # Stata prefers forward slashes in its command parser on Windows
2699
2970
  filename_for_stata = user_filename.replace("\\", "/")
2700
2971
 
2701
- cmd = "graph export"
2702
2972
  if graph_name:
2703
2973
  resolved = self._resolve_graph_name_for_stata(graph_name)
2704
- cmd += f' "{filename_for_stata}", name("{resolved}") replace as({fmt})'
2705
- else:
2706
- cmd += f' "{filename_for_stata}", replace as({fmt})'
2974
+ # Use display + export without name() for maximum compatibility.
2975
+ # name(NAME) often fails in PyStata for non-active graphs (r(693)).
2976
+ self._exec_no_capture_silent(f'quietly graph display "{resolved}"', echo=False)
2977
+
2978
+ cmd = f'quietly graph export "{filename_for_stata}", replace as({fmt})'
2707
2979
 
2708
2980
  # Avoid stdout/stderr redirection for graph export because PyStata's
2709
2981
  # output thread can crash on Windows when we swap stdio handles.
2710
- resp = self._exec_no_capture(cmd, echo=False)
2982
+ resp = self._exec_no_capture_silent(cmd, echo=False)
2711
2983
  if not resp.success:
2712
2984
  # Retry once after a short pause in case Stata had a transient file handle issue
2713
2985
  time.sleep(0.2)
2714
- resp_retry = self._exec_no_capture(cmd, echo=False)
2986
+ resp_retry = self._exec_no_capture_silent(cmd, echo=False)
2715
2987
  if not resp_retry.success:
2716
2988
  msg = resp_retry.error.message if resp_retry.error else f"graph export failed (rc={resp_retry.rc})"
2717
2989
  raise RuntimeError(msg)
@@ -2744,14 +3016,15 @@ with redirect_stdout(sys.stderr), redirect_stderr(sys.stderr):
2744
3016
  if not self._initialized:
2745
3017
  self.init()
2746
3018
 
2747
- # Try to locate the .sthlp help file
2748
- # We use 'capture' to avoid crashing if not found
2749
- self.stata.run(f"capture findfile {topic}.sthlp")
3019
+ with self._exec_lock:
3020
+ # Try to locate the .sthlp help file
3021
+ # We use 'capture' to avoid crashing if not found
3022
+ self.stata.run(f"capture findfile {topic}.sthlp")
2750
3023
 
2751
- # Retrieve the found path from r(fn)
2752
- from sfi import Macro # type: ignore[import-not-found]
2753
- self.stata.run("global mcp_help_file `r(fn)'")
2754
- fn = Macro.getGlobal("mcp_help_file")
3024
+ # Retrieve the found path from r(fn)
3025
+ from sfi import Macro # type: ignore[import-not-found]
3026
+ self.stata.run("global mcp_help_file `r(fn)'")
3027
+ fn = Macro.getGlobal("mcp_help_file")
2755
3028
 
2756
3029
  if fn and os.path.exists(fn):
2757
3030
  try:
@@ -2985,47 +3258,32 @@ with redirect_stdout(sys.stderr), redirect_stderr(sys.stderr):
2985
3258
 
2986
3259
  # Additional validation by attempting to display the graph
2987
3260
  resolved = self._resolve_graph_name_for_stata(graph_name)
2988
- cmd = f'graph display {resolved}'
2989
- resp = self._exec_no_capture(cmd, echo=False)
3261
+ cmd = f'quietly graph display {resolved}'
3262
+ resp = self._exec_no_capture_silent(cmd, echo=False)
2990
3263
  return resp.success
2991
3264
  except Exception:
2992
3265
  return False
2993
3266
 
2994
3267
  def _is_cache_valid(self, graph_name: str, cache_path: str) -> bool:
2995
- """Check if cached content is still valid."""
3268
+ """Check if cached content is still valid using internal signatures."""
2996
3269
  try:
2997
- # Get current graph content hash
2998
- import tempfile
2999
- import os
3000
-
3001
- temp_dir = tempfile.gettempdir()
3002
- temp_file = os.path.join(temp_dir, f"temp_{graph_name}_{os.getpid()}.svg")
3003
-
3004
- resolved = self._resolve_graph_name_for_stata(graph_name)
3005
- export_cmd = f'graph export "{temp_file.replace("\\\\", "/")}", name({resolved}) replace as(svg)'
3006
- resp = self._exec_no_capture(export_cmd, echo=False)
3007
-
3008
- if resp.success and os.path.exists(temp_file):
3009
- with open(temp_file, 'rb') as f:
3010
- current_data = f.read()
3011
- os.remove(temp_file)
3270
+ if not os.path.exists(cache_path) or os.path.getsize(cache_path) == 0:
3271
+ return False
3012
3272
 
3013
- current_hash = self._get_content_hash(current_data)
3014
- cached_hash = self._preemptive_cache.get(f"{graph_name}_hash")
3273
+ current_sig = self._get_graph_signature(graph_name)
3274
+ cached_sig = self._preemptive_cache.get(f"{graph_name}_sig")
3275
+
3276
+ # If we have a signature match, it's valid for the current command session
3277
+ if cached_sig and cached_sig == current_sig:
3278
+ return True
3015
3279
 
3016
- return cached_hash == current_hash
3280
+ # Otherwise it's invalid (needs refresh for new command)
3281
+ return False
3017
3282
  except Exception:
3018
- pass
3019
-
3020
- return False # Assume invalid if we can't verify
3021
-
3022
- def export_graphs_all(self, use_base64: bool = False) -> GraphExportResponse:
3023
- """Exports all graphs to file paths (default) or base64-encoded strings.
3283
+ return False
3024
3284
 
3025
- Args:
3026
- use_base64: If True, returns base64-encoded images. If False (default),
3027
- returns file paths to exported SVG files.
3028
- """
3285
+ def export_graphs_all(self) -> GraphExportResponse:
3286
+ """Exports all graphs to file paths."""
3029
3287
  exports: List[GraphExport] = []
3030
3288
  graph_names = self.list_graphs(force_refresh=True)
3031
3289
 
@@ -3035,7 +3293,6 @@ with redirect_stdout(sys.stderr), redirect_stderr(sys.stderr):
3035
3293
  import tempfile
3036
3294
  import os
3037
3295
  import threading
3038
- import base64
3039
3296
  import uuid
3040
3297
  import time
3041
3298
  import logging
@@ -3059,15 +3316,15 @@ with redirect_stdout(sys.stderr), redirect_stderr(sys.stderr):
3059
3316
  svg_path_for_stata = svg_path.replace("\\", "/")
3060
3317
 
3061
3318
  try:
3062
- export_cmd = f'graph export "{svg_path_for_stata}", name({resolved}) replace as(svg)'
3063
- export_resp = self._exec_no_capture(export_cmd, echo=False)
3319
+ export_cmd = f'quietly graph export "{svg_path_for_stata}", name({resolved}) replace as(svg)'
3320
+ export_resp = self._exec_no_capture_silent(export_cmd, echo=False)
3064
3321
 
3065
3322
  if not export_resp.success:
3066
- display_cmd = f'graph display {resolved}'
3067
- display_resp = self._exec_no_capture(display_cmd, echo=False)
3323
+ display_cmd = f'quietly graph display {resolved}'
3324
+ display_resp = self._exec_no_capture_silent(display_cmd, echo=False)
3068
3325
  if display_resp.success:
3069
- export_cmd2 = f'graph export "{svg_path_for_stata}", replace as(svg)'
3070
- export_resp = self._exec_no_capture(export_cmd2, echo=False)
3326
+ export_cmd2 = f'quietly graph export "{svg_path_for_stata}", replace as(svg)'
3327
+ export_resp = self._exec_no_capture_silent(export_cmd2, echo=False)
3071
3328
  else:
3072
3329
  export_resp = display_resp
3073
3330
 
@@ -3109,12 +3366,7 @@ with redirect_stdout(sys.stderr), redirect_stderr(sys.stderr):
3109
3366
 
3110
3367
  for name, cached_path in cached_graphs.items():
3111
3368
  try:
3112
- if use_base64:
3113
- with open(cached_path, "rb") as f:
3114
- svg_b64 = base64.b64encode(f.read()).decode("ascii")
3115
- exports.append(GraphExport(name=name, image_base64=svg_b64))
3116
- else:
3117
- exports.append(GraphExport(name=name, file_path=cached_path))
3369
+ exports.append(GraphExport(name=name, file_path=cached_path))
3118
3370
  except Exception as e:
3119
3371
  cache_errors.append(f"Failed to read cached graph {name}: {e}")
3120
3372
  # Fall back to uncached processing
@@ -3157,24 +3409,16 @@ with redirect_stdout(sys.stderr), redirect_stderr(sys.stderr):
3157
3409
  self._cache_sizes[name] = item_size
3158
3410
  self._total_cache_size += item_size
3159
3411
 
3160
- if use_base64:
3161
- svg_b64 = base64.b64encode(result).decode("ascii")
3162
- exports.append(GraphExport(name=name, image_base64=svg_b64))
3163
- else:
3164
- exports.append(GraphExport(name=name, file_path=cache_path))
3412
+ exports.append(GraphExport(name=name, file_path=cache_path))
3165
3413
  except Exception as e:
3166
3414
  cache_errors.append(f"Failed to cache graph {name}: {e}")
3167
3415
  # Still return the result even if caching fails
3168
- if use_base64:
3169
- svg_b64 = base64.b64encode(result).decode("ascii")
3170
- exports.append(GraphExport(name=name, image_base64=svg_b64))
3171
- else:
3172
- # Create temp file for immediate use
3173
- safe_name = self._sanitize_filename(name)
3174
- temp_path = os.path.join(tempfile.gettempdir(), f"{safe_name}_{uuid.uuid4().hex[:8]}.svg")
3175
- with open(temp_path, 'wb') as f:
3176
- f.write(result)
3177
- exports.append(GraphExport(name=name, file_path=temp_path))
3416
+ # Create temp file for immediate use
3417
+ safe_name = self._sanitize_filename(name)
3418
+ temp_path = os.path.join(tempfile.gettempdir(), f"{safe_name}_{uuid.uuid4().hex[:8]}.svg")
3419
+ with open(temp_path, 'wb') as f:
3420
+ f.write(result)
3421
+ exports.append(GraphExport(name=name, file_path=temp_path))
3178
3422
 
3179
3423
  # Log errors if any occurred
3180
3424
  if cache_errors:
@@ -3229,29 +3473,21 @@ with redirect_stdout(sys.stderr), redirect_stderr(sys.stderr):
3229
3473
  del self._preemptive_cache[hash_key]
3230
3474
 
3231
3475
  try:
3232
- # Sanitize graph name for file system
3233
- safe_name = self._sanitize_filename(graph_name)
3476
+ # Include signature in filename to force client-side refresh
3477
+ sig = self._get_graph_signature(graph_name)
3478
+ safe_name = self._sanitize_filename(sig)
3234
3479
  cache_path = os.path.join(self._preemptive_cache_dir, f"{safe_name}.svg")
3235
3480
  cache_path_for_stata = cache_path.replace("\\", "/")
3236
3481
 
3237
3482
  resolved_graph_name = self._resolve_graph_name_for_stata(graph_name)
3238
- graph_name_q = self._stata_quote(resolved_graph_name)
3483
+ # Use display + export without name() for maximum compatibility.
3484
+ # name(NAME) often fails in PyStata for non-active graphs (r(693)).
3485
+ # Quoting the name helps with spaces/special characters.
3486
+ display_cmd = f'quietly graph display "{resolved_graph_name}"'
3487
+ self._exec_no_capture_silent(display_cmd, echo=False)
3239
3488
 
3240
- export_cmd = f'graph export "{cache_path_for_stata}", name({graph_name_q}) replace as(svg)'
3241
- resp = self._exec_no_capture(export_cmd, echo=False)
3242
-
3243
- # Fallback: some graph names (spaces, slashes, backslashes) can confuse
3244
- # Stata's parser in name() even when the graph exists. In that case,
3245
- # make the graph current, then export without name().
3246
- if not resp.success:
3247
- try:
3248
- display_cmd = f'graph display {graph_name_q}'
3249
- display_resp = self._exec_no_capture(display_cmd, echo=False)
3250
- if display_resp.success:
3251
- export_cmd2 = f'graph export "{cache_path_for_stata}", replace as(svg)'
3252
- resp = self._exec_no_capture(export_cmd2, echo=False)
3253
- except Exception:
3254
- pass
3489
+ export_cmd = f'quietly graph export "{cache_path_for_stata}", replace as(svg)'
3490
+ resp = self._exec_no_capture_silent(export_cmd, echo=False)
3255
3491
 
3256
3492
  if resp.success and os.path.exists(cache_path) and os.path.getsize(cache_path) > 0:
3257
3493
  # Read the data to compute hash
@@ -3264,9 +3500,20 @@ with redirect_stdout(sys.stderr), redirect_stderr(sys.stderr):
3264
3500
  self._evict_cache_if_needed(item_size)
3265
3501
 
3266
3502
  with self._cache_lock:
3503
+ # Clear any old versions of this graph from the path cache
3504
+ # (Optional but keeps it clean)
3505
+ old_path = self._preemptive_cache.get(graph_name)
3506
+ if old_path and old_path != cache_path:
3507
+ try:
3508
+ os.remove(old_path)
3509
+ except Exception:
3510
+ pass
3511
+
3267
3512
  self._preemptive_cache[graph_name] = cache_path
3268
3513
  # Store content hash for validation
3269
3514
  self._preemptive_cache[f"{graph_name}_hash"] = self._get_content_hash(data)
3515
+ # Store signature for fast validation
3516
+ self._preemptive_cache[f"{graph_name}_sig"] = self._get_graph_signature(graph_name)
3270
3517
  # Update tracking
3271
3518
  self._cache_access_times[graph_name] = time.time()
3272
3519
  self._cache_sizes[graph_name] = item_size
@@ -3298,7 +3545,8 @@ with redirect_stdout(sys.stderr), redirect_stderr(sys.stderr):
3298
3545
  smcl_path = None
3299
3546
 
3300
3547
  _log_file, log_path, tail, tee = self._create_streaming_log(trace=trace)
3301
- smcl_path = self._create_smcl_log_path()
3548
+ base_dir = cwd or os.path.dirname(effective_path)
3549
+ smcl_path = self._create_smcl_log_path(base_dir=base_dir)
3302
3550
  smcl_log_name = self._make_smcl_log_name()
3303
3551
 
3304
3552
  rc = -1