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 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, Optional, List
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 = platform.system()
29
-
30
- # 1. Check Environment Variable
31
- if os.environ.get("STATA_PATH"):
32
- path = os.environ["STATA_PATH"]
33
- edition = "be"
34
- lower_path = path.lower()
35
- if "mp" in lower_path:
36
- edition = "mp"
37
- elif "se" in lower_path:
38
- edition = "se"
39
- elif "be" in lower_path:
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
- if not os.path.exists(path):
42
- raise FileNotFoundError(
43
- f"STATA_PATH points to '{path}', but that file does not exist. "
44
- "Update STATA_PATH to your Stata binary (e.g., "
45
- "/Applications/StataNow/StataMP.app/Contents/MacOS/stata-mp or /usr/local/stata18/stata-mp)."
46
- )
47
- if not os.access(path, os.X_OK):
48
- raise PermissionError(
49
- f"STATA_PATH points to '{path}', but it is not executable. "
50
- "Ensure this is the Stata binary, not the .app directory."
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
- os.environ.get("ProgramFiles", "C:\\Program Files"),
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
- for stata_dir in glob.glob(os.path.join(base_dir, "Stata*")):
87
- for exe, edition in [
88
- ("StataMP-64.exe", "mp"),
89
- ("StataMP.exe", "mp"),
90
- ("StataSE-64.exe", "se"),
91
- ("StataSE.exe", "se"),
92
- ("Stata-64.exe", "be"),
93
- ("Stata.exe", "be"),
94
- ]:
95
- full_path = os.path.join(stata_dir, exe)
96
- if os.path.exists(full_path):
97
- candidates.append((full_path, edition))
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
- linux_binaries = [
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.expanduser("~/stata"),
122
- os.path.expanduser("~/Stata"),
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 os.access(path, os.X_OK):
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, or C:\\Program Files\\Stata18\\StataMP-64.exe)."
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())