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/discovery.py +8 -4
- mcp_stata/graph_detector.py +44 -31
- mcp_stata/models.py +0 -1
- mcp_stata/server.py +129 -56
- mcp_stata/stata_client.py +587 -339
- {mcp_stata-1.13.0.dist-info → mcp_stata-1.16.6.dist-info}/METADATA +14 -4
- mcp_stata-1.16.6.dist-info/RECORD +16 -0
- mcp_stata-1.13.0.dist-info/RECORD +0 -16
- {mcp_stata-1.13.0.dist-info → mcp_stata-1.16.6.dist-info}/WHEEL +0 -0
- {mcp_stata-1.13.0.dist-info → mcp_stata-1.16.6.dist-info}/entry_points.txt +0 -0
- {mcp_stata-1.13.0.dist-info → mcp_stata-1.16.6.dist-info}/licenses/LICENSE +0 -0
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
793
|
-
|
|
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
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
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
|
-
|
|
1717
|
-
|
|
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
|
-
|
|
1952
|
+
done = anyio.Event()
|
|
1721
1953
|
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
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
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
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
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
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
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
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
|
-
|
|
1921
|
-
async
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
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
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
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
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
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
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
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
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2332
|
+
with self._exec_lock:
|
|
2333
|
+
try:
|
|
2334
|
+
# Use pystata integration to retrieve data
|
|
2335
|
+
df = self.stata.pdataframe_from_data()
|
|
2070
2336
|
|
|
2071
|
-
|
|
2072
|
-
|
|
2337
|
+
# Slice
|
|
2338
|
+
sliced = df.iloc[start : start + count]
|
|
2073
2339
|
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
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
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
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
|
-
|
|
2108
|
-
|
|
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
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
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
|
-
|
|
2838
|
+
raw_list = graph_list_str.split() if graph_list_str else []
|
|
2569
2839
|
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
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
|
-
|
|
2844
|
+
result = graph_list
|
|
2575
2845
|
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
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.
|
|
2640
|
-
save_cmd = f'graph save "{gph_path_for_stata}", replace'
|
|
2641
|
-
save_resp = self.
|
|
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
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
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
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
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.
|
|
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
|
-
|
|
2998
|
-
|
|
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
|
-
|
|
3014
|
-
|
|
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
|
-
|
|
3280
|
+
# Otherwise it's invalid (needs refresh for new command)
|
|
3281
|
+
return False
|
|
3017
3282
|
except Exception:
|
|
3018
|
-
|
|
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
|
-
|
|
3026
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
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
|
-
#
|
|
3233
|
-
|
|
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
|
-
|
|
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}",
|
|
3241
|
-
resp = self.
|
|
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
|
-
|
|
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
|