mcp-stata 1.2.2__py3-none-any.whl → 1.6.8__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 +243 -54
- mcp_stata/graph_detector.py +385 -0
- mcp_stata/models.py +4 -1
- mcp_stata/server.py +265 -44
- mcp_stata/stata_client.py +2114 -263
- mcp_stata/streaming_io.py +261 -0
- mcp_stata/ui_http.py +559 -0
- mcp_stata-1.6.8.dist-info/METADATA +388 -0
- mcp_stata-1.6.8.dist-info/RECORD +14 -0
- mcp_stata-1.2.2.dist-info/METADATA +0 -240
- mcp_stata-1.2.2.dist-info/RECORD +0 -11
- {mcp_stata-1.2.2.dist-info → mcp_stata-1.6.8.dist-info}/WHEEL +0 -0
- {mcp_stata-1.2.2.dist-info → mcp_stata-1.6.8.dist-info}/entry_points.txt +0 -0
- {mcp_stata-1.2.2.dist-info → mcp_stata-1.6.8.dist-info}/licenses/LICENSE +0 -0
mcp_stata/discovery.py
CHANGED
|
@@ -1,14 +1,43 @@
|
|
|
1
1
|
import os
|
|
2
|
+
import sys
|
|
2
3
|
import platform
|
|
3
4
|
import glob
|
|
4
5
|
import logging
|
|
5
6
|
import shutil
|
|
7
|
+
import ntpath
|
|
6
8
|
|
|
7
|
-
from typing import Tuple,
|
|
9
|
+
from typing import Tuple, List, Optional
|
|
8
10
|
|
|
9
11
|
logger = logging.getLogger("mcp_stata.discovery")
|
|
10
12
|
|
|
11
13
|
|
|
14
|
+
def _normalize_env_path(raw: str, system: str) -> str:
|
|
15
|
+
"""Strip quotes/whitespace, expand variables, and normalize slashes for STATA_PATH."""
|
|
16
|
+
cleaned = raw.strip()
|
|
17
|
+
if (cleaned.startswith('"') and cleaned.endswith('"')) or (
|
|
18
|
+
cleaned.startswith("'") and cleaned.endswith("'")
|
|
19
|
+
):
|
|
20
|
+
cleaned = cleaned[1:-1].strip()
|
|
21
|
+
|
|
22
|
+
expanded = os.path.expandvars(os.path.expanduser(cleaned))
|
|
23
|
+
|
|
24
|
+
# Always normalize path separators for the intended platform. This is especially
|
|
25
|
+
# important when running Windows discovery tests on non-Windows hosts where
|
|
26
|
+
# os.path (PosixPath) would otherwise leave backslashes untouched.
|
|
27
|
+
if system == "Windows":
|
|
28
|
+
return ntpath.normpath(expanded)
|
|
29
|
+
return os.path.normpath(expanded)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _is_executable(path: str, system: str) -> bool:
|
|
33
|
+
if not os.path.exists(path):
|
|
34
|
+
return False
|
|
35
|
+
if system == "Windows":
|
|
36
|
+
# On Windows, check if it's a file and has .exe extension
|
|
37
|
+
return os.path.isfile(path) and path.lower().endswith(".exe")
|
|
38
|
+
return os.access(path, os.X_OK)
|
|
39
|
+
|
|
40
|
+
|
|
12
41
|
def _dedupe_preserve(items: List[tuple]) -> List[tuple]:
|
|
13
42
|
seen = set()
|
|
14
43
|
unique = []
|
|
@@ -20,40 +49,155 @@ def _dedupe_preserve(items: List[tuple]) -> List[tuple]:
|
|
|
20
49
|
return unique
|
|
21
50
|
|
|
22
51
|
|
|
52
|
+
def _dedupe_str_preserve(items: List[str]) -> List[str]:
|
|
53
|
+
seen = set()
|
|
54
|
+
out: List[str] = []
|
|
55
|
+
for s in items:
|
|
56
|
+
if not s:
|
|
57
|
+
continue
|
|
58
|
+
if s in seen:
|
|
59
|
+
continue
|
|
60
|
+
seen.add(s)
|
|
61
|
+
out.append(s)
|
|
62
|
+
return out
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _resolve_windows_host_path(path: str, system: str) -> str:
|
|
66
|
+
"""
|
|
67
|
+
On non-Windows hosts running Windows-discovery code, a Windows-style path
|
|
68
|
+
(with backslashes) won't match the real filesystem layout. If the normalized
|
|
69
|
+
path does not exist and we're emulating Windows, try swapping backslashes for
|
|
70
|
+
the host separator so tests can interact with the temp filesystem.
|
|
71
|
+
"""
|
|
72
|
+
if system != "Windows":
|
|
73
|
+
return path
|
|
74
|
+
if os.path.exists(path):
|
|
75
|
+
return path
|
|
76
|
+
if os.sep != "\\" and "\\" in path:
|
|
77
|
+
alt_path = path.replace("\\", os.sep)
|
|
78
|
+
if os.path.exists(alt_path):
|
|
79
|
+
return alt_path
|
|
80
|
+
return path
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _detect_system() -> str:
|
|
84
|
+
"""
|
|
85
|
+
Prefer Windows detection via os.name / sys.platform instead of platform.system()
|
|
86
|
+
because some environments (e.g., Cygwin/MSYS) do not return "Windows".
|
|
87
|
+
"""
|
|
88
|
+
if os.name == "nt" or sys.platform.startswith(("cygwin", "msys")):
|
|
89
|
+
return "Windows"
|
|
90
|
+
return platform.system()
|
|
91
|
+
|
|
92
|
+
|
|
23
93
|
def find_stata_path() -> Tuple[str, str]:
|
|
24
94
|
"""
|
|
25
95
|
Attempts to automatically locate the Stata installation path.
|
|
26
96
|
Returns (path_to_executable, edition_string).
|
|
97
|
+
|
|
98
|
+
Behavior:
|
|
99
|
+
- If STATA_PATH is set and valid, use it.
|
|
100
|
+
- If STATA_PATH is set but invalid, fall back to auto-discovery.
|
|
101
|
+
- If auto-discovery fails, raise an error (including STATA_PATH failure context, if any).
|
|
27
102
|
"""
|
|
28
|
-
system =
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
103
|
+
system = _detect_system()
|
|
104
|
+
stata_path_error: Optional[Exception] = None
|
|
105
|
+
|
|
106
|
+
windows_binaries = [
|
|
107
|
+
("StataMP-64.exe", "mp"),
|
|
108
|
+
("StataMP.exe", "mp"),
|
|
109
|
+
("StataSE-64.exe", "se"),
|
|
110
|
+
("StataSE.exe", "se"),
|
|
111
|
+
("Stata-64.exe", "be"),
|
|
112
|
+
("Stata.exe", "be"),
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
linux_binaries = [
|
|
116
|
+
("stata-mp", "mp"),
|
|
117
|
+
("stata-se", "se"),
|
|
118
|
+
("stata-ic", "be"),
|
|
119
|
+
("stata", "be"),
|
|
120
|
+
("xstata-mp", "mp"),
|
|
121
|
+
("xstata-se", "se"),
|
|
122
|
+
("xstata-ic", "be"),
|
|
123
|
+
("xstata", "be"),
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
# 1. Check Environment Variable (supports quoted values and directory targets)
|
|
127
|
+
raw_env_path = os.environ.get("STATA_PATH")
|
|
128
|
+
if raw_env_path:
|
|
129
|
+
try:
|
|
130
|
+
path = _normalize_env_path(raw_env_path, system)
|
|
131
|
+
path = _resolve_windows_host_path(path, system)
|
|
132
|
+
logger.info("Trying STATA_PATH override (normalized): %s", path)
|
|
133
|
+
|
|
134
|
+
# If a directory is provided, try standard binaries for the platform
|
|
135
|
+
if os.path.isdir(path):
|
|
136
|
+
search_set = []
|
|
137
|
+
if system == "Windows":
|
|
138
|
+
search_set = windows_binaries
|
|
139
|
+
elif system == "Linux":
|
|
140
|
+
search_set = linux_binaries
|
|
141
|
+
elif system == "Darwin":
|
|
142
|
+
search_set = [
|
|
143
|
+
("Contents/MacOS/stata-mp", "mp"),
|
|
144
|
+
("Contents/MacOS/stata-se", "se"),
|
|
145
|
+
("Contents/MacOS/stata", "be"),
|
|
146
|
+
("stata-mp", "mp"),
|
|
147
|
+
("stata-se", "se"),
|
|
148
|
+
("stata", "be"),
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
for binary, edition in search_set:
|
|
152
|
+
candidate = os.path.join(path, binary)
|
|
153
|
+
if _is_executable(candidate, system):
|
|
154
|
+
logger.info(
|
|
155
|
+
"Found Stata via STATA_PATH directory: %s (%s)",
|
|
156
|
+
candidate,
|
|
157
|
+
edition,
|
|
158
|
+
)
|
|
159
|
+
return candidate, edition
|
|
160
|
+
|
|
161
|
+
raise FileNotFoundError(
|
|
162
|
+
f"STATA_PATH points to directory '{path}', but no Stata executable was found within. "
|
|
163
|
+
"Point STATA_PATH directly to the Stata binary "
|
|
164
|
+
"(e.g., C:\\Program Files\\Stata19\\StataMP-64.exe)."
|
|
165
|
+
)
|
|
166
|
+
|
|
40
167
|
edition = "be"
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
168
|
+
lower_path = path.lower()
|
|
169
|
+
if "mp" in lower_path:
|
|
170
|
+
edition = "mp"
|
|
171
|
+
elif "se" in lower_path:
|
|
172
|
+
edition = "se"
|
|
173
|
+
elif "be" in lower_path:
|
|
174
|
+
edition = "be"
|
|
175
|
+
|
|
176
|
+
if not os.path.exists(path):
|
|
177
|
+
raise FileNotFoundError(
|
|
178
|
+
f"STATA_PATH points to '{path}', but that file does not exist. "
|
|
179
|
+
"Update STATA_PATH to your Stata binary (e.g., "
|
|
180
|
+
"/Applications/StataNow/StataMP.app/Contents/MacOS/stata-mp, "
|
|
181
|
+
"/usr/local/stata19/stata-mp or C:\\Program Files\\Stata19Now\\StataSE-64.exe)."
|
|
182
|
+
)
|
|
183
|
+
if not _is_executable(path, system):
|
|
184
|
+
raise PermissionError(
|
|
185
|
+
f"STATA_PATH points to '{path}', but it is not executable. "
|
|
186
|
+
"Ensure this is the Stata binary, not the .app directory."
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
logger.info("Using STATA_PATH override: %s (%s)", path, edition)
|
|
190
|
+
return path, edition
|
|
191
|
+
|
|
192
|
+
except Exception as exc:
|
|
193
|
+
stata_path_error = exc
|
|
194
|
+
logger.warning(
|
|
195
|
+
"STATA_PATH override failed (%s). Falling back to auto-discovery.",
|
|
196
|
+
exc,
|
|
51
197
|
)
|
|
52
|
-
logger.info("Using STATA_PATH override: %s (%s)", path, edition)
|
|
53
|
-
return path, edition
|
|
54
198
|
|
|
55
199
|
# 2. Platform-specific search
|
|
56
|
-
candidates = [] # List of (path, edition)
|
|
200
|
+
candidates: List[Tuple[str, str]] = [] # List of (path, edition)
|
|
57
201
|
|
|
58
202
|
if system == "Darwin": # macOS
|
|
59
203
|
app_globs = [
|
|
@@ -77,36 +221,55 @@ def find_stata_path() -> Tuple[str, str]:
|
|
|
77
221
|
candidates.append((full_path, edition))
|
|
78
222
|
|
|
79
223
|
elif system == "Windows":
|
|
224
|
+
# Include ProgramW6432 (real 64-bit Program Files) and hardcode fallbacks.
|
|
225
|
+
base_dirs = _dedupe_str_preserve(
|
|
226
|
+
[
|
|
227
|
+
os.environ.get("ProgramW6432", r"C:\Program Files"),
|
|
228
|
+
os.environ.get("ProgramFiles", r"C:\Program Files"),
|
|
229
|
+
os.environ.get("ProgramFiles(Arm)", r"C:\Program Files (Arm)"),
|
|
230
|
+
os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)"),
|
|
231
|
+
r"C:\Program Files",
|
|
232
|
+
r"C:\Program Files (Arm)",
|
|
233
|
+
r"C:\Program Files (x86)",
|
|
234
|
+
]
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
# Resolve for non-Windows hosts running Windows discovery tests.
|
|
80
238
|
base_dirs = [
|
|
81
|
-
|
|
82
|
-
os.environ.get("ProgramFiles(x86)", "C:\\Program Files (x86)"),
|
|
239
|
+
_resolve_windows_host_path(ntpath.normpath(bd), system) for bd in base_dirs
|
|
83
240
|
]
|
|
241
|
+
base_dirs = _dedupe_str_preserve(base_dirs)
|
|
84
242
|
|
|
243
|
+
# Look in a few plausible layouts:
|
|
244
|
+
# base\Stata*\...
|
|
245
|
+
# base\*\Stata*\... (e.g., base\StataCorp\Stata19Now)
|
|
246
|
+
# base\Stata*\*\... (e.g., base\Stata\Stata19Now)
|
|
247
|
+
dir_globs: List[str] = []
|
|
85
248
|
for base_dir in base_dirs:
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
(
|
|
89
|
-
("
|
|
90
|
-
("
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
249
|
+
dir_globs.extend(
|
|
250
|
+
[
|
|
251
|
+
os.path.join(base_dir, "Stata*"),
|
|
252
|
+
os.path.join(base_dir, "*", "Stata*"),
|
|
253
|
+
os.path.join(base_dir, "Stata*", "Stata*"),
|
|
254
|
+
]
|
|
255
|
+
)
|
|
256
|
+
dir_globs = _dedupe_str_preserve(dir_globs)
|
|
257
|
+
|
|
258
|
+
stata_dirs: List[str] = []
|
|
259
|
+
for pattern in dir_globs:
|
|
260
|
+
stata_dirs.extend(glob.glob(pattern))
|
|
261
|
+
stata_dirs = _dedupe_str_preserve(stata_dirs)
|
|
262
|
+
|
|
263
|
+
for stata_dir in stata_dirs:
|
|
264
|
+
if not os.path.isdir(stata_dir):
|
|
265
|
+
continue
|
|
266
|
+
for exe, edition in windows_binaries:
|
|
267
|
+
full_path = os.path.join(stata_dir, exe)
|
|
268
|
+
if os.path.exists(full_path):
|
|
269
|
+
candidates.append((full_path, edition))
|
|
98
270
|
|
|
99
271
|
elif system == "Linux":
|
|
100
|
-
|
|
101
|
-
("stata-mp", "mp"),
|
|
102
|
-
("stata-se", "se"),
|
|
103
|
-
("stata-ic", "be"),
|
|
104
|
-
("stata", "be"),
|
|
105
|
-
("xstata-mp", "mp"),
|
|
106
|
-
("xstata-se", "se"),
|
|
107
|
-
("xstata-ic", "be"),
|
|
108
|
-
("xstata", "be"),
|
|
109
|
-
]
|
|
272
|
+
home_base = os.environ.get("HOME") or os.path.expanduser("~")
|
|
110
273
|
|
|
111
274
|
# 2a. Try binaries available on PATH first
|
|
112
275
|
for binary, edition in linux_binaries:
|
|
@@ -118,8 +281,8 @@ def find_stata_path() -> Tuple[str, str]:
|
|
|
118
281
|
linux_roots = [
|
|
119
282
|
"/usr/local",
|
|
120
283
|
"/opt",
|
|
121
|
-
os.path.
|
|
122
|
-
os.path.
|
|
284
|
+
os.path.join(home_base, "stata"),
|
|
285
|
+
os.path.join(home_base, "Stata"),
|
|
123
286
|
]
|
|
124
287
|
|
|
125
288
|
for root in linux_roots:
|
|
@@ -149,14 +312,40 @@ def find_stata_path() -> Tuple[str, str]:
|
|
|
149
312
|
if not os.path.exists(path):
|
|
150
313
|
logger.warning("Discovered candidate missing on disk: %s", path)
|
|
151
314
|
continue
|
|
152
|
-
if not
|
|
315
|
+
if not _is_executable(path, system):
|
|
153
316
|
logger.warning("Discovered candidate is not executable: %s", path)
|
|
154
317
|
continue
|
|
155
318
|
logger.info("Auto-discovered Stata at %s (%s)", path, edition)
|
|
156
319
|
return path, edition
|
|
157
320
|
|
|
321
|
+
if stata_path_error is not None:
|
|
322
|
+
raise FileNotFoundError(
|
|
323
|
+
"Could not automatically locate Stata after STATA_PATH failed. "
|
|
324
|
+
f"STATA_PATH error was: {stata_path_error}. "
|
|
325
|
+
"Fix STATA_PATH to point to the Stata executable, or install Stata in a standard location "
|
|
326
|
+
"(e.g., /Applications/StataNow/StataMP.app/Contents/MacOS/stata-mp, /usr/local/stata18/stata-mp, "
|
|
327
|
+
"or C:\\Program Files\\Stata18\\StataMP-64.exe)."
|
|
328
|
+
) from stata_path_error
|
|
329
|
+
|
|
158
330
|
raise FileNotFoundError(
|
|
159
331
|
"Could not automatically locate Stata. "
|
|
160
332
|
"Set STATA_PATH to your Stata executable (e.g., "
|
|
161
|
-
"/Applications/StataNow/StataMP.app/Contents/MacOS/stata-mp, /usr/local/stata18/stata-mp,
|
|
333
|
+
"/Applications/StataNow/StataMP.app/Contents/MacOS/stata-mp, /usr/local/stata18/stata-mp, "
|
|
334
|
+
"or C:\\Program Files\\Stata18\\StataMP-64.exe)."
|
|
162
335
|
)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def main() -> int:
|
|
339
|
+
"""CLI helper to print discovered Stata binary and edition."""
|
|
340
|
+
try:
|
|
341
|
+
path, edition = find_stata_path()
|
|
342
|
+
# Print so CLI users and tests see the output on stdout.
|
|
343
|
+
print(f"Stata executable: {path}\nEdition: {edition}")
|
|
344
|
+
return 0
|
|
345
|
+
except Exception as exc: # pragma: no cover - exercised via tests with env
|
|
346
|
+
print(f"Discovery failed: {exc}")
|
|
347
|
+
return 1
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
if __name__ == "__main__": # pragma: no cover - manual utility
|
|
351
|
+
raise SystemExit(main())
|