devtoolkit-mcp 0.1.0__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.
- devtoolkit_mcp/__init__.py +2 -0
- devtoolkit_mcp/server.py +23 -0
- devtoolkit_mcp/tools/__init__.py +0 -0
- devtoolkit_mcp/tools/analyze_error.py +108 -0
- devtoolkit_mcp/tools/check_dependencies.py +366 -0
- devtoolkit_mcp/tools/explain_code.py +233 -0
- devtoolkit_mcp/tools/repository.py +134 -0
- devtoolkit_mcp/tools/run_tests.py +332 -0
- devtoolkit_mcp-0.1.0.dist-info/METADATA +371 -0
- devtoolkit_mcp-0.1.0.dist-info/RECORD +12 -0
- devtoolkit_mcp-0.1.0.dist-info/WHEEL +4 -0
- devtoolkit_mcp-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import stat
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import ast
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
MAX_SOURCE_SIZE_BYTES = 1_100_000 # 1.1 MB
|
|
7
|
+
|
|
8
|
+
def _load_source_code(
|
|
9
|
+
code: str | None,
|
|
10
|
+
file_path: str | None,
|
|
11
|
+
) -> tuple[str, str]:
|
|
12
|
+
|
|
13
|
+
if (code is None) == (file_path is None):
|
|
14
|
+
raise ValueError("Provide either code or file_path, but not both."
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
if code is not None:
|
|
18
|
+
if not code.strip():
|
|
19
|
+
raise ValueError("Provided code is empty.")
|
|
20
|
+
|
|
21
|
+
if len(code.encode("utf-8")) > MAX_SOURCE_SIZE_BYTES:
|
|
22
|
+
raise ValueError("Provided code exceeds the maximum allowed size of 1.1 MB.")
|
|
23
|
+
|
|
24
|
+
return code, "code_snippet"
|
|
25
|
+
|
|
26
|
+
path = Path(file_path).expanduser().resolve()
|
|
27
|
+
|
|
28
|
+
if not path.exists():
|
|
29
|
+
raise ValueError(f"File path does not exist: {path}")
|
|
30
|
+
|
|
31
|
+
if path.is_symlink():
|
|
32
|
+
raise ValueError(f"File path is a symbolic link: {path}")
|
|
33
|
+
|
|
34
|
+
if path.suffix.lower() != ".py":
|
|
35
|
+
raise ValueError(f"File path does not point to a Python file: {path}")
|
|
36
|
+
|
|
37
|
+
file_stat = path.stat()
|
|
38
|
+
|
|
39
|
+
if not stat.S_ISREG(file_stat.st_mode):
|
|
40
|
+
raise ValueError(f"File path is not a regular file: {path}")
|
|
41
|
+
|
|
42
|
+
if file_stat.st_size > MAX_SOURCE_SIZE_BYTES:
|
|
43
|
+
raise ValueError("File size exceeds the maximum allowed size of 1.1 MB.")
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
source_code = path.read_text(encoding="utf-8")
|
|
47
|
+
|
|
48
|
+
except UnicodeDecodeError as exc:
|
|
49
|
+
raise ValueError(f"File path is not a valid UTF-8 encoded text file: {path}") from exc
|
|
50
|
+
|
|
51
|
+
return source_code, "file"
|
|
52
|
+
|
|
53
|
+
def _extract_functions(tree: ast.Module) -> list[dict]:
|
|
54
|
+
"""extract top level functions from a python AST"""
|
|
55
|
+
|
|
56
|
+
functions = []
|
|
57
|
+
|
|
58
|
+
for node in tree.body:
|
|
59
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
60
|
+
arguments = [
|
|
61
|
+
argument.arg
|
|
62
|
+
for argument in (
|
|
63
|
+
node.args.posonlyargs + node.args.args
|
|
64
|
+
)
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
if node.args.vararg:
|
|
68
|
+
arguments.append(f"*{node.args.vararg.arg}")
|
|
69
|
+
|
|
70
|
+
arguments.extend(
|
|
71
|
+
argument.arg for argument in node.args.kwonlyargs
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
if node.args.kwarg:
|
|
75
|
+
arguments.append(f"**{node.args.kwarg.arg}")
|
|
76
|
+
|
|
77
|
+
functions.append({
|
|
78
|
+
"name": node.name,
|
|
79
|
+
"arguments": arguments,
|
|
80
|
+
"line_number": node.lineno,
|
|
81
|
+
"docstring": ast.get_docstring(node),
|
|
82
|
+
"is_async": isinstance(node, ast.AsyncFunctionDef),
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
return functions
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _extract_classes(tree: ast.Module) -> list[dict]:
|
|
89
|
+
"""Extract top-level classes and their methods from Python AST."""
|
|
90
|
+
|
|
91
|
+
classes = []
|
|
92
|
+
|
|
93
|
+
for node in tree.body:
|
|
94
|
+
if isinstance(node, ast.ClassDef):
|
|
95
|
+
methods = []
|
|
96
|
+
|
|
97
|
+
for child in node.body:
|
|
98
|
+
if isinstance(
|
|
99
|
+
child,
|
|
100
|
+
(ast.FunctionDef, ast.AsyncFunctionDef),
|
|
101
|
+
):
|
|
102
|
+
arguments = [
|
|
103
|
+
argument.arg
|
|
104
|
+
for argument in (
|
|
105
|
+
child.args.posonlyargs
|
|
106
|
+
+ child.args.args
|
|
107
|
+
)
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
if child.args.vararg:
|
|
111
|
+
arguments.append(f"*{child.args.vararg.arg}")
|
|
112
|
+
|
|
113
|
+
arguments.extend(
|
|
114
|
+
argument.arg
|
|
115
|
+
for argument in child.args.kwonlyargs
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
if child.args.kwarg:
|
|
119
|
+
arguments.append(f"**{child.args.kwarg.arg}")
|
|
120
|
+
|
|
121
|
+
methods.append({
|
|
122
|
+
"name": child.name,
|
|
123
|
+
"arguments": arguments,
|
|
124
|
+
"line_number": child.lineno,
|
|
125
|
+
"docstring": ast.get_docstring(child),
|
|
126
|
+
"is_async": isinstance(
|
|
127
|
+
child,
|
|
128
|
+
ast.AsyncFunctionDef,
|
|
129
|
+
),
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
classes.append({
|
|
133
|
+
"name": node.name,
|
|
134
|
+
"line_number": node.lineno,
|
|
135
|
+
"docstring": ast.get_docstring(node),
|
|
136
|
+
"methods": methods,
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
return classes
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _extract_imports(tree: ast.Module) -> list[dict]:
|
|
143
|
+
"""Extract top-level imports from python AST"""
|
|
144
|
+
|
|
145
|
+
imports = []
|
|
146
|
+
|
|
147
|
+
for node in tree.body:
|
|
148
|
+
if isinstance(node,ast.Import):
|
|
149
|
+
for alias in node.names:
|
|
150
|
+
imports.append({
|
|
151
|
+
"type":"import",
|
|
152
|
+
"module": None,
|
|
153
|
+
"name": alias.name,
|
|
154
|
+
"alias": alias.asname,
|
|
155
|
+
"level": 0,
|
|
156
|
+
"line_number": node.lineno,
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
elif isinstance(node, ast.ImportFrom):
|
|
160
|
+
for alias in node.names:
|
|
161
|
+
imports.append({
|
|
162
|
+
"type":"from_import",
|
|
163
|
+
"module":node.module,
|
|
164
|
+
"name": alias.name,
|
|
165
|
+
"alias": alias.asname,
|
|
166
|
+
"level": node.level,
|
|
167
|
+
"line_number": node.lineno,
|
|
168
|
+
})
|
|
169
|
+
return imports
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _calculate_statistics(
|
|
173
|
+
source_code: str,
|
|
174
|
+
tree: ast.Module,
|
|
175
|
+
) -> dict:
|
|
176
|
+
|
|
177
|
+
"""Calculate basic statistics for python source code."""
|
|
178
|
+
|
|
179
|
+
functions = _extract_functions(tree)
|
|
180
|
+
classes = _extract_classes(tree)
|
|
181
|
+
imports = _extract_imports(tree)
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
"total_lines": len(source_code.splitlines()),
|
|
185
|
+
"function_count": len(functions),
|
|
186
|
+
"class_count": len(classes),
|
|
187
|
+
"import_count": len(imports)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def explain_code(
|
|
192
|
+
code: str | None = None,
|
|
193
|
+
file_path: str | None = None,
|
|
194
|
+
) -> dict:
|
|
195
|
+
"""Analyze Python code and explain its basic structure."""
|
|
196
|
+
|
|
197
|
+
source_code, source_type = _load_source_code(
|
|
198
|
+
code=code,
|
|
199
|
+
file_path=file_path,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
try:
|
|
203
|
+
tree = ast.parse(source_code)
|
|
204
|
+
|
|
205
|
+
except SyntaxError as exc:
|
|
206
|
+
return {
|
|
207
|
+
"source_type": source_type,
|
|
208
|
+
"language": "Python",
|
|
209
|
+
"syntax_valid": False,
|
|
210
|
+
"functions": [],
|
|
211
|
+
"classes": [],
|
|
212
|
+
"imports": [],
|
|
213
|
+
"statistics": None,
|
|
214
|
+
"syntax_error": {
|
|
215
|
+
"message": exc.msg,
|
|
216
|
+
"line_number": exc.lineno,
|
|
217
|
+
"column": exc.offset,
|
|
218
|
+
},
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
"source_type": source_type,
|
|
223
|
+
"language": "Python",
|
|
224
|
+
"syntax_valid": True,
|
|
225
|
+
"functions": _extract_functions(tree),
|
|
226
|
+
"classes": _extract_classes(tree),
|
|
227
|
+
"imports": _extract_imports(tree),
|
|
228
|
+
"statistics": _calculate_statistics(
|
|
229
|
+
source_code,
|
|
230
|
+
tree,
|
|
231
|
+
),
|
|
232
|
+
"syntax_error": None,
|
|
233
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from collections import Counter
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
IGNORED_DIRECTORIES = {
|
|
6
|
+
".git",
|
|
7
|
+
".venv",
|
|
8
|
+
"venv",
|
|
9
|
+
"__pycache__",
|
|
10
|
+
"node_modules",
|
|
11
|
+
".pytest_cache",
|
|
12
|
+
".mypy_cache",
|
|
13
|
+
".dist",
|
|
14
|
+
"build",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
LANGUAGE_EXTENSIONS = {
|
|
18
|
+
".py": "Python",
|
|
19
|
+
".js": "JavaScript",
|
|
20
|
+
".ts": "TypeScript",
|
|
21
|
+
".jsx": "JavaScript",
|
|
22
|
+
".tsx": "TypeScript",
|
|
23
|
+
".java": "Java",
|
|
24
|
+
".cpp": "C++",
|
|
25
|
+
".c": "C",
|
|
26
|
+
".go": "Go",
|
|
27
|
+
".rs": "Rust",
|
|
28
|
+
".rb": "Ruby",
|
|
29
|
+
".php": "PHP",
|
|
30
|
+
".sql": "SQL",
|
|
31
|
+
".html": "HTML",
|
|
32
|
+
".css": "CSS",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
def analyze_repository(
|
|
36
|
+
repository_path: str,
|
|
37
|
+
max_files: int = 5000,
|
|
38
|
+
) -> dict:
|
|
39
|
+
"""
|
|
40
|
+
Analyze the repository at the given path and return a summary of its contents.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
repository_path (str): The path to the repository.
|
|
44
|
+
max_files (int): The maximum number of files to analyze.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
dict: A summary of the repository's contents, including total files,
|
|
48
|
+
total lines of code, and a breakdown of file types.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
if max_files < 1 or max_files > 20000:
|
|
52
|
+
raise ValueError("max_files must be between 1 and 20000.")
|
|
53
|
+
|
|
54
|
+
root = Path(repository_path).expanduser().resolve()
|
|
55
|
+
|
|
56
|
+
if not root.exists():
|
|
57
|
+
raise ValueError(f"Repository path does not exist:{root}")
|
|
58
|
+
|
|
59
|
+
if not root.is_dir():
|
|
60
|
+
raise ValueError(f"Repository path is not a directory: {root}")
|
|
61
|
+
|
|
62
|
+
language_counts = Counter()
|
|
63
|
+
configuration_files = []
|
|
64
|
+
directory_count = 0
|
|
65
|
+
files_scanned = 0
|
|
66
|
+
truncated = False
|
|
67
|
+
|
|
68
|
+
important_files = {
|
|
69
|
+
"pyproject.toml",
|
|
70
|
+
"requirements.txt",
|
|
71
|
+
"package.json",
|
|
72
|
+
"Cargo.toml",
|
|
73
|
+
"go.mod",
|
|
74
|
+
"pom.xml",
|
|
75
|
+
"Dockerfile",
|
|
76
|
+
"docker-compose.yml",
|
|
77
|
+
"alembic.ini",
|
|
78
|
+
"pytest.ini",
|
|
79
|
+
"README.md",
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
top_level_entries = sorted(
|
|
83
|
+
entry.name for entry in root.iterdir() if entry.is_dir() and entry.name not in IGNORED_DIRECTORIES
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
for current_root, directories, files in os.walk(
|
|
87
|
+
root,
|
|
88
|
+
followlinks=False,
|
|
89
|
+
):
|
|
90
|
+
directories[:] = [
|
|
91
|
+
directory
|
|
92
|
+
for directory in directories
|
|
93
|
+
if directory not in IGNORED_DIRECTORIES
|
|
94
|
+
and not (Path(current_root) / directory).is_symlink()
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
directory_count += len(directories)
|
|
98
|
+
|
|
99
|
+
for filename in files:
|
|
100
|
+
file_path = Path(current_root) / filename
|
|
101
|
+
|
|
102
|
+
if file_path.is_symlink():
|
|
103
|
+
continue
|
|
104
|
+
|
|
105
|
+
if files_scanned >= max_files:
|
|
106
|
+
truncated = True
|
|
107
|
+
break
|
|
108
|
+
|
|
109
|
+
files_scanned += 1
|
|
110
|
+
|
|
111
|
+
extension = file_path.suffix.lower()
|
|
112
|
+
|
|
113
|
+
if extension in LANGUAGE_EXTENSIONS:
|
|
114
|
+
language_counts[LANGUAGE_EXTENSIONS[extension]] += 1
|
|
115
|
+
|
|
116
|
+
if filename in important_files:
|
|
117
|
+
configuration_files.append(str(file_path.relative_to(root)))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
if truncated:
|
|
121
|
+
break
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
"project_name": root.name,
|
|
126
|
+
"repository_path": str(root),
|
|
127
|
+
"files_scanned": files_scanned,
|
|
128
|
+
"directories": directory_count,
|
|
129
|
+
"languages": dict(language_counts.most_common()),
|
|
130
|
+
"important_files": sorted(configuration_files),
|
|
131
|
+
"top_level_entries": top_level_entries,
|
|
132
|
+
"truncated": truncated,
|
|
133
|
+
}
|
|
134
|
+
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import signal
|
|
3
|
+
import ast
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
import threading
|
|
7
|
+
import tomllib
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _validate_project_path(project_path: str) -> Path:
|
|
12
|
+
"""Validate the supplied project directory."""
|
|
13
|
+
|
|
14
|
+
if not isinstance(project_path, str):
|
|
15
|
+
raise TypeError("Project path must be a string.")
|
|
16
|
+
|
|
17
|
+
if not project_path.strip():
|
|
18
|
+
raise ValueError("Project path cannot be empty.")
|
|
19
|
+
|
|
20
|
+
root = Path(project_path).expanduser()
|
|
21
|
+
|
|
22
|
+
if not root.exists():
|
|
23
|
+
raise ValueError("Project directory does not exist.")
|
|
24
|
+
|
|
25
|
+
if root.is_symlink():
|
|
26
|
+
raise ValueError("Symbolic links are not supported.")
|
|
27
|
+
|
|
28
|
+
if not root.is_dir():
|
|
29
|
+
raise ValueError("Path must point to a directory.")
|
|
30
|
+
|
|
31
|
+
return root.resolve()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _has_pytest_config(project_root: Path) -> bool:
|
|
35
|
+
"""check whether the project has explicit pytest configuration"""
|
|
36
|
+
|
|
37
|
+
if (project_root/"pytest.ini").is_file():
|
|
38
|
+
return True
|
|
39
|
+
|
|
40
|
+
pyproject_file = project_root/"pyproject.toml"
|
|
41
|
+
|
|
42
|
+
if pyproject_file.is_file():
|
|
43
|
+
try:
|
|
44
|
+
with pyproject_file.open("rb") as file:
|
|
45
|
+
config = tomllib.load(file)
|
|
46
|
+
|
|
47
|
+
if "pytest" in config.get("tool", {}):
|
|
48
|
+
return True
|
|
49
|
+
|
|
50
|
+
except (tomllib.TOMLDecodeError, OSError):
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
config_files = {
|
|
54
|
+
"setup.cfg": "[tool:pytest]",
|
|
55
|
+
"tox.ini":"[pytest]",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
for filename, section in config_files.items():
|
|
59
|
+
config_file = project_root/filename
|
|
60
|
+
|
|
61
|
+
if config_file.is_file():
|
|
62
|
+
try:
|
|
63
|
+
content = config_file.read_text(encoding="utf-8").lower()
|
|
64
|
+
if section.lower() in content:
|
|
65
|
+
return True
|
|
66
|
+
|
|
67
|
+
except (OSError, UnicodeDecodeError):
|
|
68
|
+
continue
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _get_test_framework_imports(project_root: Path) -> set[str]:
|
|
73
|
+
"""Find pytest and unittest imports in test files"""
|
|
74
|
+
|
|
75
|
+
test_files = set(project_root.glob("test_*.py"))
|
|
76
|
+
test_files.update(project_root.glob("*_test.py"))
|
|
77
|
+
|
|
78
|
+
tests_directory = project_root/ "tests"
|
|
79
|
+
|
|
80
|
+
if tests_directory.is_dir() and not tests_directory.is_symlink():
|
|
81
|
+
test_files.update(tests_directory.rglob("*.py"))
|
|
82
|
+
|
|
83
|
+
detected = set()
|
|
84
|
+
|
|
85
|
+
for test_file in test_files:
|
|
86
|
+
if test_file.is_symlink():
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
source = test_file.read_text(encoding="utf-8")
|
|
91
|
+
tree = ast.parse(source)
|
|
92
|
+
except (OSError, UnicodeDecodeError, SyntaxError):
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
for node in ast.walk(tree):
|
|
96
|
+
if isinstance(node, ast.Import):
|
|
97
|
+
for alias in node.names:
|
|
98
|
+
if alias.name == "pytest":
|
|
99
|
+
detected.add("pytest")
|
|
100
|
+
if alias.name == "unittest":
|
|
101
|
+
detected.add("unittest")
|
|
102
|
+
elif isinstance(node, ast.ImportFrom):
|
|
103
|
+
if node.module == "pytest" or (
|
|
104
|
+
node.module and node.module.startswith("pytest.")
|
|
105
|
+
):
|
|
106
|
+
detected.add("pytest")
|
|
107
|
+
if node.module == "unittest" or (
|
|
108
|
+
node.module and node.module.startswith("unittest.")
|
|
109
|
+
):
|
|
110
|
+
detected.add("unittest")
|
|
111
|
+
return detected
|
|
112
|
+
|
|
113
|
+
def _detect_test_framework(project_root: Path) -> dict:
|
|
114
|
+
"""Detect pytest and unittest using configuration and imports"""
|
|
115
|
+
|
|
116
|
+
detected = _get_test_framework_imports(project_root)
|
|
117
|
+
|
|
118
|
+
if _has_pytest_config(project_root):
|
|
119
|
+
detected.add("pytest")
|
|
120
|
+
|
|
121
|
+
frameworks = sorted(detected)
|
|
122
|
+
|
|
123
|
+
if len(frameworks) > 1:
|
|
124
|
+
status = "multiple_detected"
|
|
125
|
+
elif len(frameworks) == 1:
|
|
126
|
+
status = "detected"
|
|
127
|
+
else:
|
|
128
|
+
status = "not_found"
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
"frameworks": frameworks,
|
|
132
|
+
"status": status,
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _get_python_executable(project_root: Path) -> str:
|
|
137
|
+
"""Find the Python executable for the target project"""
|
|
138
|
+
|
|
139
|
+
if sys.platform == "win32":
|
|
140
|
+
python_path = project_root / ".venv" / "Scripts" / "python.exe"
|
|
141
|
+
else:
|
|
142
|
+
python_path = project_root / ".venv" / "bin" / "python"
|
|
143
|
+
|
|
144
|
+
if python_path.is_file():
|
|
145
|
+
return str(python_path)
|
|
146
|
+
|
|
147
|
+
return sys.executable
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _read_process_output(
|
|
151
|
+
pipe,
|
|
152
|
+
output: bytearray,
|
|
153
|
+
max_bytes: int = 10000,
|
|
154
|
+
) -> None:
|
|
155
|
+
"""Read process output while retaining only a limited amount."""
|
|
156
|
+
|
|
157
|
+
while True:
|
|
158
|
+
chunk = pipe.read(1024)
|
|
159
|
+
|
|
160
|
+
if not chunk:
|
|
161
|
+
break
|
|
162
|
+
|
|
163
|
+
remaining = max_bytes - len(output)
|
|
164
|
+
|
|
165
|
+
if remaining > 0:
|
|
166
|
+
output.extend(chunk[:remaining])
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _terminate_process_tree(process: subprocess.Popen) -> None:
|
|
170
|
+
"""Terminate a process and its child processes."""
|
|
171
|
+
|
|
172
|
+
if process.poll() is not None:
|
|
173
|
+
return
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
if os.name == "nt":
|
|
177
|
+
subprocess.run(
|
|
178
|
+
["taskkill", "/F", "/T", "/PID", str(process.pid)],
|
|
179
|
+
capture_output=True,
|
|
180
|
+
timeout=10,
|
|
181
|
+
shell=False,
|
|
182
|
+
check=False,
|
|
183
|
+
)
|
|
184
|
+
else:
|
|
185
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
186
|
+
|
|
187
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
188
|
+
pass
|
|
189
|
+
|
|
190
|
+
if process.poll() is None:
|
|
191
|
+
process.kill()
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _execute_tests(
|
|
195
|
+
project_root: Path,
|
|
196
|
+
command: list[str],
|
|
197
|
+
timeout: int = 120,
|
|
198
|
+
) -> dict:
|
|
199
|
+
"""Execute tests with bounded output and timeout handling."""
|
|
200
|
+
|
|
201
|
+
output = bytearray()
|
|
202
|
+
process = None
|
|
203
|
+
|
|
204
|
+
try:
|
|
205
|
+
process = subprocess.Popen(
|
|
206
|
+
command,
|
|
207
|
+
cwd=project_root,
|
|
208
|
+
stdout=subprocess.PIPE,
|
|
209
|
+
stderr=subprocess.STDOUT,
|
|
210
|
+
shell=False,
|
|
211
|
+
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0,
|
|
212
|
+
start_new_session=os.name != "nt",
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
reader = threading.Thread(
|
|
216
|
+
target=_read_process_output,
|
|
217
|
+
args=(process.stdout, output),
|
|
218
|
+
daemon=True,
|
|
219
|
+
)
|
|
220
|
+
reader.start()
|
|
221
|
+
|
|
222
|
+
try:
|
|
223
|
+
return_code = process.wait(timeout=timeout)
|
|
224
|
+
|
|
225
|
+
except subprocess.TimeoutExpired:
|
|
226
|
+
_terminate_process_tree(process)
|
|
227
|
+
process.wait()
|
|
228
|
+
reader.join(timeout=5)
|
|
229
|
+
|
|
230
|
+
if reader.is_alive():
|
|
231
|
+
process.stdout.close()
|
|
232
|
+
reader.join(timeout=1)
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
"status": "timeout",
|
|
236
|
+
"return_code": None,
|
|
237
|
+
"output": output.decode("utf-8", errors="replace"),
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
reader.join()
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
"status": "completed",
|
|
244
|
+
"return_code": return_code,
|
|
245
|
+
"output": output.decode("utf-8", errors="replace"),
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
except OSError as exc:
|
|
249
|
+
if process is not None and process.poll() is None:
|
|
250
|
+
process.kill()
|
|
251
|
+
process.wait()
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
"status": "error",
|
|
255
|
+
"return_code": None,
|
|
256
|
+
"output": str(exc)[:10000],
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def run_tests(
|
|
262
|
+
project_path: str,
|
|
263
|
+
timeout: int = 120,
|
|
264
|
+
) -> dict:
|
|
265
|
+
"""Run detected Python tests in a project"""
|
|
266
|
+
|
|
267
|
+
try:
|
|
268
|
+
project_root = _validate_project_path(project_path)
|
|
269
|
+
|
|
270
|
+
if isinstance(timeout, bool) or not isinstance(timeout, int):
|
|
271
|
+
raise TypeError("Timeout must be an integer")
|
|
272
|
+
|
|
273
|
+
if not 1<=timeout<=600:
|
|
274
|
+
raise ValueError("Timeout must be between 1 and 600 seconds.")
|
|
275
|
+
|
|
276
|
+
detection = _detect_test_framework(project_root)
|
|
277
|
+
frameworks = detection["frameworks"]
|
|
278
|
+
|
|
279
|
+
if not frameworks:
|
|
280
|
+
return {
|
|
281
|
+
"status": "not_found",
|
|
282
|
+
"framework": None,
|
|
283
|
+
"return_code": None,
|
|
284
|
+
"output": "No supported test framework detected"
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if "pytest" in frameworks:
|
|
288
|
+
framework = "pytest"
|
|
289
|
+
command = [
|
|
290
|
+
_get_python_executable(project_root),
|
|
291
|
+
"-m",
|
|
292
|
+
"pytest",
|
|
293
|
+
"-q",
|
|
294
|
+
]
|
|
295
|
+
else:
|
|
296
|
+
framework = "unittest"
|
|
297
|
+
command = [
|
|
298
|
+
_get_python_executable(project_root),
|
|
299
|
+
"-m",
|
|
300
|
+
"unittest",
|
|
301
|
+
"discover",
|
|
302
|
+
]
|
|
303
|
+
result = _execute_tests(
|
|
304
|
+
project_root=project_root,
|
|
305
|
+
command=command,
|
|
306
|
+
timeout=timeout,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
if result["status"] == "completed":
|
|
310
|
+
status = (
|
|
311
|
+
"passed"
|
|
312
|
+
if result["return_code"] == 0
|
|
313
|
+
else "failed"
|
|
314
|
+
)
|
|
315
|
+
else:
|
|
316
|
+
status = result["status"]
|
|
317
|
+
|
|
318
|
+
return {
|
|
319
|
+
"status": status,
|
|
320
|
+
"framework": framework,
|
|
321
|
+
"return_code": result["return_code"],
|
|
322
|
+
"output": result["output"],
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
except (ValueError, TypeError) as exc:
|
|
326
|
+
return {
|
|
327
|
+
"status": "error",
|
|
328
|
+
"framework": None,
|
|
329
|
+
"return_code": None,
|
|
330
|
+
"output": str(exc),
|
|
331
|
+
}
|
|
332
|
+
|