aigroup-stata-mcp 1.0.3__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.
- aigroup_stata_mcp-1.0.3.dist-info/METADATA +345 -0
- aigroup_stata_mcp-1.0.3.dist-info/RECORD +38 -0
- aigroup_stata_mcp-1.0.3.dist-info/WHEEL +4 -0
- aigroup_stata_mcp-1.0.3.dist-info/entry_points.txt +5 -0
- aigroup_stata_mcp-1.0.3.dist-info/licenses/LICENSE +21 -0
- stata_mcp/__init__.py +18 -0
- stata_mcp/cli/__init__.py +8 -0
- stata_mcp/cli/_cli.py +95 -0
- stata_mcp/core/__init__.py +14 -0
- stata_mcp/core/data_info/__init__.py +11 -0
- stata_mcp/core/data_info/_base.py +288 -0
- stata_mcp/core/data_info/csv.py +123 -0
- stata_mcp/core/data_info/dta.py +70 -0
- stata_mcp/core/stata/__init__.py +13 -0
- stata_mcp/core/stata/stata_controller/__init__.py +9 -0
- stata_mcp/core/stata/stata_controller/controller.py +208 -0
- stata_mcp/core/stata/stata_do/__init__.py +9 -0
- stata_mcp/core/stata/stata_do/do.py +177 -0
- stata_mcp/core/stata/stata_finder/__init__.py +9 -0
- stata_mcp/core/stata/stata_finder/base.py +294 -0
- stata_mcp/core/stata/stata_finder/finder.py +193 -0
- stata_mcp/core/stata/stata_finder/linux.py +43 -0
- stata_mcp/core/stata/stata_finder/macos.py +88 -0
- stata_mcp/core/stata/stata_finder/windows.py +191 -0
- stata_mcp/server/__init__.py +8 -0
- stata_mcp/server/main.py +153 -0
- stata_mcp/server/prompts/__init__.py +8 -0
- stata_mcp/server/prompts/core_prompts.py +122 -0
- stata_mcp/server/tools/__init__.py +10 -0
- stata_mcp/server/tools/core_tools.py +59 -0
- stata_mcp/server/tools/file_tools.py +163 -0
- stata_mcp/server/tools/stata_tools.py +221 -0
- stata_mcp/utils/Installer/__init__.py +7 -0
- stata_mcp/utils/Installer/installer.py +85 -0
- stata_mcp/utils/Prompt/__init__.py +74 -0
- stata_mcp/utils/Prompt/string.py +91 -0
- stata_mcp/utils/__init__.py +23 -0
- stata_mcp/utils/usable.py +244 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
#!/usr/bin/python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
import pexpect
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class StataController:
|
|
12
|
+
|
|
13
|
+
"""Stata控制器类,用于管理Stata会话和执行命令"""
|
|
14
|
+
def __init__(self, stata_cli: str = None, timeout: int = 30):
|
|
15
|
+
"""
|
|
16
|
+
Initialize the Stata controller.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
stata_cli (str): Path to the Stata command-line executable.
|
|
20
|
+
timeout (int): Timeout for command execution (in seconds).
|
|
21
|
+
"""
|
|
22
|
+
self.stata_cli_path = stata_cli
|
|
23
|
+
self.child = None
|
|
24
|
+
self.timeout = timeout
|
|
25
|
+
self.start()
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def STATA_CLI(self):
|
|
29
|
+
"""获取Stata CLI路径"""
|
|
30
|
+
return self.stata_cli_path
|
|
31
|
+
|
|
32
|
+
def _expect_prompt(self, timeout=None):
|
|
33
|
+
"""
|
|
34
|
+
Wait for the Stata prompt, indicating command completion.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
timeout (int, optional): Timeout for this wait; if not provided, use default.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
int: The index returned by pexpect.expect.
|
|
41
|
+
"""
|
|
42
|
+
if timeout is None:
|
|
43
|
+
timeout = self.timeout
|
|
44
|
+
|
|
45
|
+
# Use a set of patterns to match various prompt scenarios
|
|
46
|
+
patterns = [
|
|
47
|
+
r"\r\n\. ", # Standard prompt
|
|
48
|
+
r"\r\n: ", # Continuation prompt
|
|
49
|
+
r"\r\n--more--", # More content prompt
|
|
50
|
+
r"r\(\d+\);", # Error prompt
|
|
51
|
+
pexpect.TIMEOUT, # Timeout
|
|
52
|
+
pexpect.EOF, # End of program
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
index = self.child.expect(patterns, timeout=timeout)
|
|
56
|
+
|
|
57
|
+
# Handle matched patterns
|
|
58
|
+
if index == 2: # --more-- prompt; send space to continue
|
|
59
|
+
self.child.send(" ")
|
|
60
|
+
return self._expect_prompt(timeout) # Recurse until actual prompt
|
|
61
|
+
elif index == 3: # Error prompt
|
|
62
|
+
# Continue waiting until standard prompt appears
|
|
63
|
+
try:
|
|
64
|
+
self.child.expect(r"\r\n\. ", timeout=5)
|
|
65
|
+
except pexpect.TIMEOUT:
|
|
66
|
+
pass # Ignore timeout and return error index
|
|
67
|
+
return index
|
|
68
|
+
elif index == 4: # Timeout
|
|
69
|
+
# Try sending a newline to trigger the prompt
|
|
70
|
+
self.child.sendline("")
|
|
71
|
+
try:
|
|
72
|
+
return self.child.expect(
|
|
73
|
+
[r"\r\n\. ", pexpect.TIMEOUT], timeout=5)
|
|
74
|
+
except pexpect.TIMEOUT:
|
|
75
|
+
return index
|
|
76
|
+
|
|
77
|
+
return index
|
|
78
|
+
|
|
79
|
+
def run(self, command, timeout=None):
|
|
80
|
+
"""
|
|
81
|
+
Execute a Stata command and wait for completion.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
command (str): The Stata command to execute.
|
|
85
|
+
timeout (int, optional): Timeout for this command.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
str: The output of the command execution.
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
RuntimeError: If the command times out or other errors occur.
|
|
92
|
+
"""
|
|
93
|
+
if timeout is None:
|
|
94
|
+
timeout = self.timeout
|
|
95
|
+
|
|
96
|
+
# Send the command
|
|
97
|
+
self.child.sendline(command)
|
|
98
|
+
|
|
99
|
+
# Wait for the command to complete
|
|
100
|
+
result = self._expect_prompt(timeout)
|
|
101
|
+
|
|
102
|
+
# Capture the output
|
|
103
|
+
output = self.child.before.strip()
|
|
104
|
+
|
|
105
|
+
# Check for errors
|
|
106
|
+
if result == 3: # Error prompt index
|
|
107
|
+
error_match = re.search(r"r\((\d+)\);", output)
|
|
108
|
+
if error_match:
|
|
109
|
+
error_code = error_match.group(1)
|
|
110
|
+
raise RuntimeError(f"Stata error r({error_code}): {output}")
|
|
111
|
+
elif result == 4: # Timeout
|
|
112
|
+
raise RuntimeError(f"Command timed out (> {timeout}s): {command}")
|
|
113
|
+
elif result == 5: # EOF
|
|
114
|
+
raise RuntimeError(
|
|
115
|
+
f"Stata session terminated unexpectedly: {output}")
|
|
116
|
+
|
|
117
|
+
return output
|
|
118
|
+
|
|
119
|
+
def run_with_retry(self, command, max_retries=3, timeout=None):
|
|
120
|
+
"""
|
|
121
|
+
Execute a command with a retry mechanism.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
command (str): The Stata command to execute.
|
|
125
|
+
max_retries (int): Maximum number of retry attempts.
|
|
126
|
+
timeout (int, optional): Timeout for this command.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
str: The output of the command execution.
|
|
130
|
+
|
|
131
|
+
Raises:
|
|
132
|
+
RuntimeError: If all retry attempts fail.
|
|
133
|
+
"""
|
|
134
|
+
retries = 0
|
|
135
|
+
last_error = None
|
|
136
|
+
|
|
137
|
+
while retries < max_retries:
|
|
138
|
+
try:
|
|
139
|
+
return self.run(command, timeout)
|
|
140
|
+
except RuntimeError as e:
|
|
141
|
+
last_error = e
|
|
142
|
+
retries += 1
|
|
143
|
+
# If it's a timeout error and we can retry, restart the session
|
|
144
|
+
if "timed out" in str(e) and retries < max_retries:
|
|
145
|
+
self.restart()
|
|
146
|
+
time.sleep(1) # Brief pause before retry
|
|
147
|
+
|
|
148
|
+
# All retries failed
|
|
149
|
+
raise RuntimeError(
|
|
150
|
+
f"Command failed after {max_retries} attempts: {last_error}")
|
|
151
|
+
|
|
152
|
+
def start(self):
|
|
153
|
+
"""
|
|
154
|
+
Start the Stata session.
|
|
155
|
+
"""
|
|
156
|
+
self.child = pexpect.spawn(
|
|
157
|
+
self.STATA_CLI, encoding="utf-8", timeout=self.timeout
|
|
158
|
+
)
|
|
159
|
+
self._expect_prompt()
|
|
160
|
+
|
|
161
|
+
def restart(self):
|
|
162
|
+
"""
|
|
163
|
+
Restart the Stata session.
|
|
164
|
+
"""
|
|
165
|
+
self.close()
|
|
166
|
+
self.start()
|
|
167
|
+
|
|
168
|
+
def close(self):
|
|
169
|
+
"""
|
|
170
|
+
Close the Stata session.
|
|
171
|
+
"""
|
|
172
|
+
if self.child and not self.child.closed:
|
|
173
|
+
try:
|
|
174
|
+
self.child.sendline("exit, clear")
|
|
175
|
+
self.child.expect(pexpect.EOF, timeout=5)
|
|
176
|
+
except Exception as e:
|
|
177
|
+
print(
|
|
178
|
+
f"Warning: Could not close Stata session with error: {e}")
|
|
179
|
+
finally:
|
|
180
|
+
self.child.close()
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
if __name__ == "__main__":
|
|
184
|
+
url = "https://pub-b55c5837ee41480ba0f902096dd9725d.r2.dev/01_OLS.dta"
|
|
185
|
+
stata_cli = "stata-mp"
|
|
186
|
+
var_list = [] # e.g., ["weight", "height"]
|
|
187
|
+
var_str = " ".join(var_list) if var_list else ""
|
|
188
|
+
|
|
189
|
+
# Use a longer timeout for the session
|
|
190
|
+
temp_stata_session = StataController(stata_cli=stata_cli, timeout=60)
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
# Execute command with retry
|
|
194
|
+
use_data = temp_stata_session.run_with_retry(f"use {url}, clear")
|
|
195
|
+
if "not found" in use_data or "server reported server error" in use_data:
|
|
196
|
+
print("Stata data not found. Please check the path.")
|
|
197
|
+
else:
|
|
198
|
+
# For commands that may require more time, specify a longer timeout
|
|
199
|
+
summarize = temp_stata_session.run(
|
|
200
|
+
f"summarize {var_str}", timeout=120)
|
|
201
|
+
describe = temp_stata_session.run(f"describe {var_str}")
|
|
202
|
+
result = {"summarize": summarize, "describe": describe}
|
|
203
|
+
print(result.get("summarize"))
|
|
204
|
+
except Exception as e:
|
|
205
|
+
print(f"Error: {e}")
|
|
206
|
+
finally:
|
|
207
|
+
# Ensure the session is properly closed
|
|
208
|
+
temp_stata_session.close()
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
#!/usr/bin/python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
|
|
9
|
+
from ....utils import get_nowtime
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class StataDo:
|
|
13
|
+
|
|
14
|
+
"""Stata do文件执行器,用于执行Stata脚本并管理日志"""
|
|
15
|
+
def __init__(self,
|
|
16
|
+
stata_cli: str,
|
|
17
|
+
log_file_path: str,
|
|
18
|
+
dofile_base_path: str,
|
|
19
|
+
sys_os: str = None):
|
|
20
|
+
"""
|
|
21
|
+
Initialize Stata executor
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
stata_cli: Path to Stata command line tool
|
|
25
|
+
log_file_path: Path for storing log files
|
|
26
|
+
dofile_base_path: Base path for do files
|
|
27
|
+
sys_os: Operating system type
|
|
28
|
+
"""
|
|
29
|
+
self.stata_cli = stata_cli
|
|
30
|
+
self.log_file_path = log_file_path
|
|
31
|
+
self.dofile_base_path = dofile_base_path
|
|
32
|
+
if sys_os:
|
|
33
|
+
self.sys_os = sys_os
|
|
34
|
+
else:
|
|
35
|
+
from ....utils import get_os
|
|
36
|
+
self.sys_os = get_os()
|
|
37
|
+
|
|
38
|
+
def set_cli(self, cli_path):
|
|
39
|
+
"""设置Stata CLI路径"""
|
|
40
|
+
self.stata_cli = cli_path
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def STATA_CLI(self):
|
|
44
|
+
"""获取Stata CLI路径"""
|
|
45
|
+
return self.stata_cli
|
|
46
|
+
|
|
47
|
+
def execute_dofile(self,
|
|
48
|
+
dofile_path: str,
|
|
49
|
+
log_file_name: str = None,
|
|
50
|
+
is_replace: bool = True) -> str:
|
|
51
|
+
"""
|
|
52
|
+
Execute Stata do file and return log file path
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
dofile_path (str): Path to do file
|
|
56
|
+
log_file_name (str, optional): File name of log
|
|
57
|
+
is_replace (bool): Whether replace the log file if exists before. Default is True
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
str: Path to generated log file
|
|
61
|
+
|
|
62
|
+
Raises:
|
|
63
|
+
ValueError: Unsupported operating system
|
|
64
|
+
RuntimeError: Stata execution error
|
|
65
|
+
"""
|
|
66
|
+
nowtime = get_nowtime()
|
|
67
|
+
log_name = log_file_name or nowtime
|
|
68
|
+
log_file = os.path.join(self.log_file_path, f"{log_name}.log")
|
|
69
|
+
|
|
70
|
+
if self.sys_os == "Darwin" or self.sys_os == "Linux":
|
|
71
|
+
self._execute_unix_like(dofile_path, log_file, is_replace)
|
|
72
|
+
elif self.sys_os == "Windows":
|
|
73
|
+
self._execute_windows(dofile_path, log_file, nowtime, is_replace)
|
|
74
|
+
else:
|
|
75
|
+
raise ValueError(f"Unsupported operating system: {self.sys_os}")
|
|
76
|
+
|
|
77
|
+
return log_file
|
|
78
|
+
|
|
79
|
+
def _execute_unix_like(self, dofile_path: str, log_file: str, is_replace: bool = True):
|
|
80
|
+
"""
|
|
81
|
+
Execute Stata on macOS/Linux systems
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
dofile_path: Path to do file
|
|
85
|
+
log_file: Path to log file
|
|
86
|
+
is_replace: Whether replace the log file if exists.
|
|
87
|
+
|
|
88
|
+
Raises:
|
|
89
|
+
RuntimeError: Stata execution error
|
|
90
|
+
"""
|
|
91
|
+
proc = subprocess.Popen(
|
|
92
|
+
[self.STATA_CLI], # Launch the Stata CLI
|
|
93
|
+
stdin=subprocess.PIPE, # Prepare to send commands
|
|
94
|
+
stdout=subprocess.PIPE,
|
|
95
|
+
stderr=subprocess.PIPE,
|
|
96
|
+
text=True,
|
|
97
|
+
shell=True, # Required when the path contains spaces
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# Execute commands sequentially in Stata
|
|
101
|
+
replace_clause = ", replace" if is_replace else ""
|
|
102
|
+
|
|
103
|
+
commands = f"""
|
|
104
|
+
log using "{log_file}"{replace_clause}
|
|
105
|
+
do "{dofile_path}"
|
|
106
|
+
log close
|
|
107
|
+
exit, STATA
|
|
108
|
+
"""
|
|
109
|
+
stdout, stderr = proc.communicate(
|
|
110
|
+
input=commands
|
|
111
|
+
) # Send commands and wait for completion
|
|
112
|
+
|
|
113
|
+
if proc.returncode != 0:
|
|
114
|
+
logging.error(f"Stata execution failed: {stderr}")
|
|
115
|
+
raise RuntimeError(f"Something went wrong: {stderr}")
|
|
116
|
+
else:
|
|
117
|
+
logging.info(
|
|
118
|
+
f"Stata execution completed successfully. Log file: {log_file}")
|
|
119
|
+
|
|
120
|
+
def _execute_windows(self, dofile_path: str, log_file: str, nowtime: str, is_replace: bool = True):
|
|
121
|
+
"""
|
|
122
|
+
Execute Stata on Windows systems
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
dofile_path: Path to do file
|
|
126
|
+
log_file: Path to log file
|
|
127
|
+
nowtime: Timestamp for generating temporary file names
|
|
128
|
+
"""
|
|
129
|
+
# Windows approach - use the /e /q flags for clean batch processing
|
|
130
|
+
# Create a temporary batch file
|
|
131
|
+
batch_file = os.path.join(self.dofile_base_path, f"{nowtime}_batch.do")
|
|
132
|
+
|
|
133
|
+
replace_clause = ", replace" if is_replace else ""
|
|
134
|
+
try:
|
|
135
|
+
with open(batch_file, "w", encoding="utf-8") as f:
|
|
136
|
+
f.write(f'log using "{log_file}"{replace_clause}\n')
|
|
137
|
+
f.write(f'do "{dofile_path}"\n')
|
|
138
|
+
f.write("log close\n")
|
|
139
|
+
f.write("exit, STATA\n")
|
|
140
|
+
|
|
141
|
+
# Run Stata on Windows using /e /q for clean batch processing
|
|
142
|
+
# /e: batch mode (execute and exit)
|
|
143
|
+
# /q: quiet mode (no startup messages)
|
|
144
|
+
cmd = f'"{self.STATA_CLI}" /e /q do "{batch_file}"'
|
|
145
|
+
result = subprocess.run(
|
|
146
|
+
cmd, shell=True, capture_output=True, text=True)
|
|
147
|
+
|
|
148
|
+
if result.returncode != 0:
|
|
149
|
+
logging.error(
|
|
150
|
+
f"Stata execution failed on Windows: {result.stderr}")
|
|
151
|
+
raise RuntimeError(
|
|
152
|
+
f"Windows Stata execution failed: {result.stderr}")
|
|
153
|
+
else:
|
|
154
|
+
logging.info(
|
|
155
|
+
f"Stata execution completed successfully on Windows. Log file: {log_file}")
|
|
156
|
+
|
|
157
|
+
except Exception as e:
|
|
158
|
+
logging.error(f"Error during Windows Stata execution: {str(e)}")
|
|
159
|
+
raise
|
|
160
|
+
finally:
|
|
161
|
+
# Clean up temporary batch file
|
|
162
|
+
if os.path.exists(batch_file):
|
|
163
|
+
try:
|
|
164
|
+
os.remove(batch_file)
|
|
165
|
+
logging.debug(
|
|
166
|
+
f"Temporary batch file removed: {batch_file}")
|
|
167
|
+
except Exception as e:
|
|
168
|
+
logging.warning(
|
|
169
|
+
f"Failed to remove temporary batch file "
|
|
170
|
+
f"{batch_file}: {str(e)}")
|
|
171
|
+
|
|
172
|
+
@staticmethod
|
|
173
|
+
def read_log(log_file_path, mode="r", encoding="utf-8") -> str:
|
|
174
|
+
"""读取Stata日志文件内容"""
|
|
175
|
+
with open(log_file_path, mode, encoding=encoding) as file:
|
|
176
|
+
log_content = file.read()
|
|
177
|
+
return log_content
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
#!/usr/bin/python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Dict, Iterable, List, Optional, Union
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class StataEditionConfig:
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
"""
|
|
18
|
+
StataEditionConfig class for comparing Stata versions with sorting support.
|
|
19
|
+
|
|
20
|
+
Attributes:
|
|
21
|
+
edition (str): Edition type (mp > se > be > ic > default)
|
|
22
|
+
version (Union[int, float]): Version number (e.g., 18, 19.5). Default 99 if not found, indicating current default version
|
|
23
|
+
path (str): Full path to Stata executable
|
|
24
|
+
|
|
25
|
+
Comparison Rules:
|
|
26
|
+
1. First compare edition priority: mp > se > be > ic > default (edition type always has priority)
|
|
27
|
+
2. Then compare numeric version: higher > lower (only for same edition type)
|
|
28
|
+
3. Support float versions like 19.5 > 19
|
|
29
|
+
4. Version 99 is used when version info is not available (default edition gets highest version within its type)
|
|
30
|
+
|
|
31
|
+
Example:
|
|
32
|
+
>>> p1 = StataEditionConfig("mp", 18, "/usr/local/bin/stata-mp")
|
|
33
|
+
>>> p2 = StataEditionConfig.from_path("/usr/local/bin/stata") # Auto-detect as default with version 99
|
|
34
|
+
>>> p1 > p2 # True (mp edition has higher priority than default, regardless of version numbers)
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
edition: str
|
|
38
|
+
version: Union[int, float]
|
|
39
|
+
path: str
|
|
40
|
+
|
|
41
|
+
# Edition priority mapping
|
|
42
|
+
_EDITION_PRIORITY = {
|
|
43
|
+
"mp": 5,
|
|
44
|
+
"se": 4,
|
|
45
|
+
"be": 3,
|
|
46
|
+
"ic": 2,
|
|
47
|
+
"default": 1,
|
|
48
|
+
"unknown": 0,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
def __post_init__(self):
|
|
52
|
+
"""Validation and processing after initialization."""
|
|
53
|
+
# Normalize edition type to lowercase
|
|
54
|
+
self.edition = self.edition.lower()
|
|
55
|
+
|
|
56
|
+
# If edition type is not in priority mapping, mark as unknown
|
|
57
|
+
if self.edition not in self._EDITION_PRIORITY:
|
|
58
|
+
self.edition = "unknown"
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def from_path(cls, path: str) -> 'StataEditionConfig':
|
|
62
|
+
"""
|
|
63
|
+
Create StataEditionConfig from path, automatically extracting edition and version.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
path: Full path to Stata executable
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
StataEditionConfig with auto-detected edition and version
|
|
70
|
+
"""
|
|
71
|
+
import os
|
|
72
|
+
|
|
73
|
+
filename = os.path.basename(path).lower()
|
|
74
|
+
full_path_lower = path.lower()
|
|
75
|
+
|
|
76
|
+
# Extract edition
|
|
77
|
+
edition = "default"
|
|
78
|
+
edition_patterns = [
|
|
79
|
+
(r'stata-mp', 'mp'),
|
|
80
|
+
(r'satamp', 'mp'),
|
|
81
|
+
(r'stata-se', 'se'),
|
|
82
|
+
(r'statase', 'se'),
|
|
83
|
+
(r'sata-be', 'be'),
|
|
84
|
+
(r'satabe', 'be'),
|
|
85
|
+
(r'sata-ic', 'ic'),
|
|
86
|
+
(r'sataic', 'ic'),
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
for pattern, ed in edition_patterns:
|
|
90
|
+
if re.search(pattern, filename):
|
|
91
|
+
edition = ed
|
|
92
|
+
break
|
|
93
|
+
|
|
94
|
+
# Extract version (default to 99 if not found, indicating current default version)
|
|
95
|
+
version = 99
|
|
96
|
+
|
|
97
|
+
# Try to extract version from directory name first
|
|
98
|
+
dir_version_match = re.search(r'stata(\d+(?:\.\d+)?)', full_path_lower)
|
|
99
|
+
if dir_version_match:
|
|
100
|
+
try:
|
|
101
|
+
version = float(dir_version_match.group(1))
|
|
102
|
+
except ValueError:
|
|
103
|
+
pass
|
|
104
|
+
|
|
105
|
+
# Try to extract version from filename
|
|
106
|
+
file_version_patterns = [
|
|
107
|
+
r'stata-[a-z]+-(\d+(?:\.\d+)?)', # stata-mp-17.5
|
|
108
|
+
r'stata(\d+(?:\.\d+)?)', # stata17.5
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
for pattern in file_version_patterns:
|
|
112
|
+
file_version_match = re.search(pattern, filename)
|
|
113
|
+
if file_version_match:
|
|
114
|
+
try:
|
|
115
|
+
file_version = float(file_version_match.group(1))
|
|
116
|
+
# Only use reasonable version numbers (1-30)
|
|
117
|
+
if 1 <= file_version <= 30:
|
|
118
|
+
version = max(version, file_version)
|
|
119
|
+
break
|
|
120
|
+
except ValueError:
|
|
121
|
+
continue
|
|
122
|
+
|
|
123
|
+
return cls(edition=edition, version=version, path=path)
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def edition_priority(self) -> int:
|
|
127
|
+
"""Get the priority value of the edition type."""
|
|
128
|
+
return self._EDITION_PRIORITY[self.edition]
|
|
129
|
+
|
|
130
|
+
def __lt__(self, other) -> bool:
|
|
131
|
+
"""Less than comparison for sorting."""
|
|
132
|
+
if not isinstance(other, StataEditionConfig):
|
|
133
|
+
return NotImplemented
|
|
134
|
+
|
|
135
|
+
# First compare edition priority
|
|
136
|
+
if self.edition_priority != other.edition_priority:
|
|
137
|
+
return self.edition_priority < other.edition_priority
|
|
138
|
+
|
|
139
|
+
# Same edition, compare version number
|
|
140
|
+
return self.version < other.version
|
|
141
|
+
|
|
142
|
+
def __le__(self, other) -> bool:
|
|
143
|
+
"""Less than or equal comparison."""
|
|
144
|
+
return self < other or self == other
|
|
145
|
+
|
|
146
|
+
def __gt__(self, other) -> bool:
|
|
147
|
+
"""Greater than comparison."""
|
|
148
|
+
if not isinstance(other, StataEditionConfig):
|
|
149
|
+
return NotImplemented
|
|
150
|
+
|
|
151
|
+
# First compare edition priority
|
|
152
|
+
if self.edition_priority != other.edition_priority:
|
|
153
|
+
return self.edition_priority > other.edition_priority
|
|
154
|
+
|
|
155
|
+
# Same edition, compare version number
|
|
156
|
+
return self.version > other.version
|
|
157
|
+
|
|
158
|
+
def __ge__(self, other) -> bool:
|
|
159
|
+
"""Greater than or equal comparison."""
|
|
160
|
+
return self > other or self == other
|
|
161
|
+
|
|
162
|
+
def __eq__(self, other) -> bool:
|
|
163
|
+
"""Equality comparison."""
|
|
164
|
+
if not isinstance(other, StataEditionConfig):
|
|
165
|
+
return NotImplemented
|
|
166
|
+
|
|
167
|
+
return (self.edition_priority == other.edition_priority and
|
|
168
|
+
self.version == other.version)
|
|
169
|
+
|
|
170
|
+
def __str__(self) -> str:
|
|
171
|
+
"""String representation - returns the path."""
|
|
172
|
+
return self.path
|
|
173
|
+
|
|
174
|
+
def __repr__(self) -> str:
|
|
175
|
+
"""Detailed string representation - returns the path."""
|
|
176
|
+
return self.path
|
|
177
|
+
|
|
178
|
+
def __int__(self) -> int:
|
|
179
|
+
"""Integer conversion - returns the version number."""
|
|
180
|
+
return int(self.version)
|
|
181
|
+
|
|
182
|
+
def __float__(self) -> float:
|
|
183
|
+
"""Float conversion - returns the version number."""
|
|
184
|
+
return float(self.version)
|
|
185
|
+
|
|
186
|
+
@property
|
|
187
|
+
def stata_cli_path(self) -> str:
|
|
188
|
+
"""Get the Stata CLI path."""
|
|
189
|
+
return self.path
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class FinderBase(ABC):
|
|
193
|
+
stata_cli: str = None
|
|
194
|
+
|
|
195
|
+
def __init__(self, stata_cli: str = None):
|
|
196
|
+
# If there is any setting, use the input and environment first
|
|
197
|
+
self.stata_cli = stata_cli or os.getenv("STATA_CLI") or os.getenv("stata_cli")
|
|
198
|
+
|
|
199
|
+
def find_stata(self) -> str | None:
|
|
200
|
+
if self.stata_cli:
|
|
201
|
+
return self.stata_cli
|
|
202
|
+
return self.finder()
|
|
203
|
+
|
|
204
|
+
@abstractmethod
|
|
205
|
+
def finder(self) -> str:
|
|
206
|
+
"""
|
|
207
|
+
Find the Stata executable on the current platform.
|
|
208
|
+
|
|
209
|
+
This method must be implemented by each platform-specific finder class
|
|
210
|
+
to locate Stata installations using platform-appropriate search strategies.
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
str: The full path to the Stata executable
|
|
214
|
+
|
|
215
|
+
Raises:
|
|
216
|
+
FileNotFoundError: If no Stata installation is found
|
|
217
|
+
|
|
218
|
+
Note:
|
|
219
|
+
This is an abstract method and must be implemented by concrete finder classes
|
|
220
|
+
such as FinderMacOS, FinderWindows, or FinderLinux.
|
|
221
|
+
|
|
222
|
+
Each platform should implement appropriate search strategies for finding
|
|
223
|
+
Stata installations in their typical locations (e.g., /Applications for macOS,
|
|
224
|
+
Program Files for Windows, system PATH for Linux).
|
|
225
|
+
"""
|
|
226
|
+
...
|
|
227
|
+
|
|
228
|
+
@abstractmethod
|
|
229
|
+
def find_path_base(self) -> Dict[str, List[str]]:
|
|
230
|
+
"""简要描述函数功能"""
|
|
231
|
+
...
|
|
232
|
+
|
|
233
|
+
@staticmethod
|
|
234
|
+
def priority() -> Dict[str, List[str]]:
|
|
235
|
+
"""简要描述函数功能"""
|
|
236
|
+
name_priority = {
|
|
237
|
+
"mp": ["stata-mp"],
|
|
238
|
+
"se": ["stata-se"],
|
|
239
|
+
"be": ["stata-be"],
|
|
240
|
+
"default": ["stata"],
|
|
241
|
+
}
|
|
242
|
+
return name_priority
|
|
243
|
+
|
|
244
|
+
@staticmethod
|
|
245
|
+
def _is_executable(p: Path) -> bool:
|
|
246
|
+
"""简要描述函数功能"""
|
|
247
|
+
try:
|
|
248
|
+
return p.is_file() and os.access(p, os.X_OK)
|
|
249
|
+
except OSError:
|
|
250
|
+
return False
|
|
251
|
+
|
|
252
|
+
def find_from_bin(self,
|
|
253
|
+
*,
|
|
254
|
+
priority: Optional[Iterable[str]] = None) -> List[StataEditionConfig]:
|
|
255
|
+
"""
|
|
256
|
+
Find all available Stata executables in bin directories.
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
priority: Edition priority order (default: ["mp", "se", "be", "default"])
|
|
260
|
+
|
|
261
|
+
Returns:
|
|
262
|
+
List of all executable Stata paths found in bin directories,
|
|
263
|
+
ordered by priority. Returns empty list if no executables found.
|
|
264
|
+
"""
|
|
265
|
+
pr = list(priority) if priority else ["mp", "se", "be", "default"]
|
|
266
|
+
name_priority = self.priority()
|
|
267
|
+
bins = self.find_path_base().get("bin")
|
|
268
|
+
|
|
269
|
+
if not bins:
|
|
270
|
+
return []
|
|
271
|
+
|
|
272
|
+
# Build ordered list of executable names by priority
|
|
273
|
+
ordered_names: List[str] = []
|
|
274
|
+
for key in pr:
|
|
275
|
+
ordered_names.extend(name_priority.get(key, []))
|
|
276
|
+
|
|
277
|
+
found_executables: List[StataEditionConfig] = []
|
|
278
|
+
|
|
279
|
+
# Search for executables in all bin directories
|
|
280
|
+
for b in bins:
|
|
281
|
+
base = Path(b)
|
|
282
|
+
|
|
283
|
+
# Check weather the bin directory exists
|
|
284
|
+
if not base.exists():
|
|
285
|
+
continue
|
|
286
|
+
|
|
287
|
+
for name in ordered_names:
|
|
288
|
+
p = base / name
|
|
289
|
+
if self._is_executable(p):
|
|
290
|
+
# Convert path to StataEditionConfig
|
|
291
|
+
config = StataEditionConfig.from_path(str(p))
|
|
292
|
+
found_executables.append(config)
|
|
293
|
+
|
|
294
|
+
return found_executables
|