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.
@@ -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
+