vscode-common-python-lsp 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.
- vscode_common_python_lsp/__init__.py +137 -0
- vscode_common_python_lsp/code_actions.py +131 -0
- vscode_common_python_lsp/context.py +61 -0
- vscode_common_python_lsp/debug.py +49 -0
- vscode_common_python_lsp/diagnostics.py +275 -0
- vscode_common_python_lsp/formatting.py +48 -0
- vscode_common_python_lsp/jsonrpc.py +306 -0
- vscode_common_python_lsp/linting.py +62 -0
- vscode_common_python_lsp/notebook.py +245 -0
- vscode_common_python_lsp/paths.py +254 -0
- vscode_common_python_lsp/process_runner.py +94 -0
- vscode_common_python_lsp/runner.py +174 -0
- vscode_common_python_lsp/server.py +487 -0
- vscode_common_python_lsp/version.py +64 -0
- vscode_common_python_lsp-0.1.0.dist-info/METADATA +22 -0
- vscode_common_python_lsp-0.1.0.dist-info/RECORD +18 -0
- vscode_common_python_lsp-0.1.0.dist-info/WHEEL +5 -0
- vscode_common_python_lsp-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation. All rights reserved.
|
|
2
|
+
# Licensed under the MIT License.
|
|
3
|
+
"""Path utilities for VS Code Python tool extensions."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import fnmatch
|
|
8
|
+
import functools
|
|
9
|
+
import os
|
|
10
|
+
import pathlib
|
|
11
|
+
import site
|
|
12
|
+
import sys
|
|
13
|
+
import sysconfig
|
|
14
|
+
import threading
|
|
15
|
+
from enum import Enum
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
# Save the working directory used when loading this module
|
|
19
|
+
SERVER_CWD = os.getcwd()
|
|
20
|
+
CWD_LOCK = threading.Lock()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class PythonFileKind(Enum):
|
|
24
|
+
"""Classification of a Python file by its location in the environment."""
|
|
25
|
+
|
|
26
|
+
STDLIB = "stdlib"
|
|
27
|
+
USER_SITE = "user_site"
|
|
28
|
+
SYSTEM_SITE = "system_site"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def as_list(content: Any) -> list[Any]:
|
|
32
|
+
"""Ensures we always get a list."""
|
|
33
|
+
if isinstance(content, (list, tuple)):
|
|
34
|
+
return list(content)
|
|
35
|
+
return [content]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_sys_config_paths() -> list[str]:
|
|
39
|
+
"""Returns Python installation paths from sysconfig.get_paths().
|
|
40
|
+
|
|
41
|
+
Uses the broader filter (not in data/platdata/scripts) to match
|
|
42
|
+
black, isort, mypy, and pylint. Includes stdlib, platstdlib,
|
|
43
|
+
purelib, platlib, include, and platinclude.
|
|
44
|
+
"""
|
|
45
|
+
return [
|
|
46
|
+
path
|
|
47
|
+
for group, path in sysconfig.get_paths().items()
|
|
48
|
+
if group not in ["data", "platdata", "scripts"]
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_extensions_dir() -> list[str]:
|
|
53
|
+
"""Returns the VS Code extensions folder (under ~/.vscode or ~/.vscode-server).
|
|
54
|
+
|
|
55
|
+
The path is calculated relative to this file, because users can launch
|
|
56
|
+
VS Code with a custom extensions folder using the --extensions-dir argument.
|
|
57
|
+
"""
|
|
58
|
+
path = pathlib.Path(__file__).parent.parent.parent.parent
|
|
59
|
+
# ^ bundled ^ extensions
|
|
60
|
+
# tool <extension>
|
|
61
|
+
if path.name == "extensions":
|
|
62
|
+
return [os.fspath(path)]
|
|
63
|
+
return []
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
# Path classification (lazy, cached)
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@functools.lru_cache(maxsize=1)
|
|
72
|
+
def _get_stdlib_roots() -> frozenset[pathlib.Path]:
|
|
73
|
+
"""Resolved stdlib and platstdlib paths plus VS Code extensions dir.
|
|
74
|
+
|
|
75
|
+
Uses only the strict stdlib/platstdlib sysconfig groups so that
|
|
76
|
+
purelib/platlib (which point to site-packages) are excluded.
|
|
77
|
+
|
|
78
|
+
The result is computed lazily on the first call, then cached for
|
|
79
|
+
the lifetime of the process (paths do not change at runtime).
|
|
80
|
+
"""
|
|
81
|
+
roots: set[pathlib.Path] = set()
|
|
82
|
+
for group in ("stdlib", "platstdlib"):
|
|
83
|
+
p = sysconfig.get_path(group)
|
|
84
|
+
if p:
|
|
85
|
+
try:
|
|
86
|
+
roots.add(pathlib.Path(p).resolve())
|
|
87
|
+
except OSError:
|
|
88
|
+
pass
|
|
89
|
+
for p in get_extensions_dir():
|
|
90
|
+
try:
|
|
91
|
+
roots.add(pathlib.Path(p).resolve())
|
|
92
|
+
except OSError:
|
|
93
|
+
pass
|
|
94
|
+
return frozenset(roots)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@functools.lru_cache(maxsize=1)
|
|
98
|
+
def _get_user_site_root() -> pathlib.Path | None:
|
|
99
|
+
"""Resolved user site-packages path, or None.
|
|
100
|
+
|
|
101
|
+
The result is computed lazily on the first call, then cached for
|
|
102
|
+
the lifetime of the process.
|
|
103
|
+
"""
|
|
104
|
+
try:
|
|
105
|
+
raw = site.getusersitepackages()
|
|
106
|
+
except Exception:
|
|
107
|
+
return None
|
|
108
|
+
if raw:
|
|
109
|
+
try:
|
|
110
|
+
return pathlib.Path(raw).resolve()
|
|
111
|
+
except OSError:
|
|
112
|
+
pass
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@functools.lru_cache(maxsize=1)
|
|
117
|
+
def _get_system_site_roots() -> frozenset[pathlib.Path]:
|
|
118
|
+
"""All system site-packages roots.
|
|
119
|
+
|
|
120
|
+
Includes purelib/platlib from sysconfig plus every entry from
|
|
121
|
+
``site.getsitepackages()``. On Windows Store Python the latter
|
|
122
|
+
includes the base installation directory, which is a *parent* of
|
|
123
|
+
the stdlib root — the classifier handles this by checking stdlib
|
|
124
|
+
first.
|
|
125
|
+
|
|
126
|
+
The result is computed lazily on the first call, then cached for
|
|
127
|
+
the lifetime of the process.
|
|
128
|
+
"""
|
|
129
|
+
roots: set[pathlib.Path] = set()
|
|
130
|
+
for group in ("purelib", "platlib"):
|
|
131
|
+
p = sysconfig.get_path(group)
|
|
132
|
+
if p:
|
|
133
|
+
try:
|
|
134
|
+
roots.add(pathlib.Path(p).resolve())
|
|
135
|
+
except OSError:
|
|
136
|
+
pass
|
|
137
|
+
try:
|
|
138
|
+
for p in as_list(site.getsitepackages()):
|
|
139
|
+
try:
|
|
140
|
+
roots.add(pathlib.Path(p).resolve())
|
|
141
|
+
except OSError:
|
|
142
|
+
pass
|
|
143
|
+
except Exception:
|
|
144
|
+
pass
|
|
145
|
+
return frozenset(roots)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def classify_python_file(file_path: str) -> PythonFileKind | None:
|
|
149
|
+
"""Classify a file as stdlib, user site-packages, or system site-packages.
|
|
150
|
+
|
|
151
|
+
Returns None if the file does not belong to any known Python
|
|
152
|
+
installation path. Resolution order:
|
|
153
|
+
|
|
154
|
+
1. User site-packages (most specific user path).
|
|
155
|
+
2. Stdlib roots *excluding* ``site-packages``/``dist-packages``
|
|
156
|
+
subdirectories — checked before system site-packages because on
|
|
157
|
+
some platforms (Windows Store Python) ``site.getsitepackages()``
|
|
158
|
+
includes the base installation directory, which is a parent of
|
|
159
|
+
the stdlib root.
|
|
160
|
+
3. System site-packages (purelib, platlib, broad base roots).
|
|
161
|
+
"""
|
|
162
|
+
try:
|
|
163
|
+
resolved = pathlib.Path(file_path).resolve()
|
|
164
|
+
except OSError:
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
# 1. User site-packages (most specific)
|
|
168
|
+
user_site = _get_user_site_root()
|
|
169
|
+
if user_site and resolved.is_relative_to(user_site):
|
|
170
|
+
return PythonFileKind.USER_SITE
|
|
171
|
+
|
|
172
|
+
parts = resolved.parts
|
|
173
|
+
has_site_packages = "site-packages" in parts or "dist-packages" in parts
|
|
174
|
+
|
|
175
|
+
# 2. Stdlib (only if the path does NOT traverse a site-packages dir)
|
|
176
|
+
if not has_site_packages:
|
|
177
|
+
for root in _get_stdlib_roots():
|
|
178
|
+
if resolved.is_relative_to(root):
|
|
179
|
+
return PythonFileKind.STDLIB
|
|
180
|
+
|
|
181
|
+
# 3. System site-packages (includes broad roots like the base dir)
|
|
182
|
+
for root in _get_system_site_roots():
|
|
183
|
+
if resolved.is_relative_to(root):
|
|
184
|
+
return PythonFileKind.SYSTEM_SITE
|
|
185
|
+
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# ---------------------------------------------------------------------------
|
|
190
|
+
# Path comparison and normalization
|
|
191
|
+
# ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def is_same_path(
|
|
195
|
+
file_path1: str, file_path2: str, resolve_symlinks: bool = False
|
|
196
|
+
) -> bool:
|
|
197
|
+
"""Returns true if two paths are the same.
|
|
198
|
+
|
|
199
|
+
When *resolve_symlinks* is True, both paths are resolved through the
|
|
200
|
+
filesystem first so that symlinked paths compare equal (mirrors the
|
|
201
|
+
behaviour required by mypy). Falls back to lexical comparison on
|
|
202
|
+
OSError (e.g. the file does not exist yet).
|
|
203
|
+
"""
|
|
204
|
+
p1, p2 = pathlib.Path(file_path1), pathlib.Path(file_path2)
|
|
205
|
+
if resolve_symlinks:
|
|
206
|
+
try:
|
|
207
|
+
return p1.resolve() == p2.resolve()
|
|
208
|
+
except OSError:
|
|
209
|
+
pass
|
|
210
|
+
return p1 == p2
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def normalize_path(file_path: str, resolve_symlinks: bool = True) -> str:
|
|
214
|
+
"""Returns normalized path."""
|
|
215
|
+
path = pathlib.Path(file_path)
|
|
216
|
+
if resolve_symlinks:
|
|
217
|
+
path = path.resolve()
|
|
218
|
+
return str(path)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def is_current_interpreter(executable: str) -> bool:
|
|
222
|
+
"""Returns true if the executable path is same as the current interpreter."""
|
|
223
|
+
return is_same_path(executable, sys.executable)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def get_relative_path(file_path: str, workspace_root: str) -> str:
|
|
227
|
+
"""Returns the file path relative to the workspace root.
|
|
228
|
+
|
|
229
|
+
Falls back to the original path if the workspace root is empty or
|
|
230
|
+
the paths are on different drives (Windows).
|
|
231
|
+
"""
|
|
232
|
+
if not workspace_root:
|
|
233
|
+
return pathlib.Path(file_path).as_posix()
|
|
234
|
+
try:
|
|
235
|
+
return pathlib.Path(file_path).relative_to(workspace_root).as_posix()
|
|
236
|
+
except ValueError:
|
|
237
|
+
return pathlib.Path(file_path).as_posix()
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def is_match(
|
|
241
|
+
patterns: list[str], file_path: str, workspace_root: str | None = None
|
|
242
|
+
) -> bool:
|
|
243
|
+
"""Returns true if the file matches one of the fnmatch patterns."""
|
|
244
|
+
if not patterns:
|
|
245
|
+
return False
|
|
246
|
+
relative_path = (
|
|
247
|
+
get_relative_path(file_path, workspace_root) if workspace_root else file_path
|
|
248
|
+
)
|
|
249
|
+
file_name = pathlib.Path(file_path).name
|
|
250
|
+
return any(
|
|
251
|
+
fnmatch.fnmatch(relative_path, pattern)
|
|
252
|
+
or (not pattern.startswith("/") and fnmatch.fnmatch(file_name, pattern))
|
|
253
|
+
for pattern in patterns
|
|
254
|
+
)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation. All rights reserved.
|
|
2
|
+
# Licensed under the MIT License.
|
|
3
|
+
"""Runner to use when running under a different interpreter."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import traceback
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from .context import substitute_attr
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from .jsonrpc import JsonRpc
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def update_sys_path(path_to_add: str, strategy: str) -> None:
|
|
20
|
+
"""Add given path to ``sys.path``.
|
|
21
|
+
|
|
22
|
+
Parameters
|
|
23
|
+
----------
|
|
24
|
+
path_to_add:
|
|
25
|
+
The directory to add.
|
|
26
|
+
strategy:
|
|
27
|
+
``"useBundled"`` inserts at position 0 (highest priority);
|
|
28
|
+
any other value appends.
|
|
29
|
+
"""
|
|
30
|
+
if path_to_add not in sys.path and os.path.isdir(path_to_add):
|
|
31
|
+
if strategy == "useBundled":
|
|
32
|
+
sys.path.insert(0, path_to_add)
|
|
33
|
+
else:
|
|
34
|
+
sys.path.append(path_to_add)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def run_message_loop(
|
|
38
|
+
rpc: JsonRpc,
|
|
39
|
+
run_fn: Callable[..., object],
|
|
40
|
+
result_cls: Callable[[str, str], object],
|
|
41
|
+
) -> None:
|
|
42
|
+
"""Process JSON-RPC messages in a loop until ``exit`` is received.
|
|
43
|
+
|
|
44
|
+
Parameters
|
|
45
|
+
----------
|
|
46
|
+
rpc:
|
|
47
|
+
A ``JsonRpc`` instance connected to the parent process.
|
|
48
|
+
run_fn:
|
|
49
|
+
Callable with signature ``(module, argv, use_stdin, cwd, source=None)``
|
|
50
|
+
that returns a result object with ``.stdout`` and ``.stderr`` attrs.
|
|
51
|
+
result_cls:
|
|
52
|
+
Callable ``(stdout, stderr) -> result`` used to wrap exceptions.
|
|
53
|
+
"""
|
|
54
|
+
exit_now = False
|
|
55
|
+
while not exit_now:
|
|
56
|
+
msg = rpc.receive_data()
|
|
57
|
+
|
|
58
|
+
method = msg["method"]
|
|
59
|
+
if method == "exit":
|
|
60
|
+
exit_now = True
|
|
61
|
+
continue
|
|
62
|
+
|
|
63
|
+
if method == "run":
|
|
64
|
+
is_exception = False
|
|
65
|
+
with substitute_attr(sys, "path", [""] + sys.path[:]):
|
|
66
|
+
try:
|
|
67
|
+
result = run_fn(
|
|
68
|
+
module=msg["module"],
|
|
69
|
+
argv=msg["argv"],
|
|
70
|
+
use_stdin=msg["useStdin"],
|
|
71
|
+
cwd=msg["cwd"],
|
|
72
|
+
source=msg.get("source"),
|
|
73
|
+
)
|
|
74
|
+
except Exception:
|
|
75
|
+
result = result_cls("", traceback.format_exc(chain=True))
|
|
76
|
+
is_exception = True
|
|
77
|
+
|
|
78
|
+
response: dict[str, object] = {
|
|
79
|
+
"id": msg["id"],
|
|
80
|
+
"error": result.stderr,
|
|
81
|
+
}
|
|
82
|
+
if is_exception:
|
|
83
|
+
response["exception"] = is_exception
|
|
84
|
+
elif result.stdout is not None:
|
|
85
|
+
response["result"] = result.stdout
|
|
86
|
+
|
|
87
|
+
rpc.send_data(response)
|
|
88
|
+
else:
|
|
89
|
+
rpc.send_data(
|
|
90
|
+
{
|
|
91
|
+
"id": msg.get("id", ""),
|
|
92
|
+
"error": f"Unknown method: {method}",
|
|
93
|
+
}
|
|
94
|
+
)
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation. All rights reserved.
|
|
2
|
+
# Licensed under the MIT License.
|
|
3
|
+
"""Tool execution runners for VS Code Python tool extensions."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import contextlib
|
|
8
|
+
import io
|
|
9
|
+
import os
|
|
10
|
+
import runpy
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
from collections.abc import Callable, Iterator, Sequence
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
from .context import change_cwd, redirect_io, substitute_attr
|
|
17
|
+
from .paths import CWD_LOCK, is_same_path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class RunResult:
|
|
22
|
+
"""Object to hold result from running tool."""
|
|
23
|
+
|
|
24
|
+
stdout: str
|
|
25
|
+
stderr: str
|
|
26
|
+
exit_code: int | str | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CustomIO(io.TextIOWrapper):
|
|
30
|
+
"""Custom stream object to replace stdio."""
|
|
31
|
+
|
|
32
|
+
name = None
|
|
33
|
+
|
|
34
|
+
def __init__(self, name, encoding="utf-8", newline=None):
|
|
35
|
+
self._buffer = io.BytesIO()
|
|
36
|
+
super().__init__(self._buffer, encoding=encoding, newline=newline)
|
|
37
|
+
self.name = name
|
|
38
|
+
|
|
39
|
+
def close(self):
|
|
40
|
+
"""Provide this close method which is used by some tools."""
|
|
41
|
+
# This is intentionally empty.
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
def get_value(self) -> str:
|
|
45
|
+
"""Returns value from the buffer as string."""
|
|
46
|
+
self.seek(0)
|
|
47
|
+
return self.read()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@contextlib.contextmanager
|
|
51
|
+
def _cwd_lock(cwd: str) -> Iterator[None]:
|
|
52
|
+
"""Acquire CWD_LOCK and optionally change directory."""
|
|
53
|
+
with CWD_LOCK:
|
|
54
|
+
if is_same_path(os.getcwd(), cwd):
|
|
55
|
+
yield
|
|
56
|
+
else:
|
|
57
|
+
with change_cwd(cwd):
|
|
58
|
+
yield
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def run_module(
|
|
62
|
+
module: str,
|
|
63
|
+
argv: Sequence[str],
|
|
64
|
+
use_stdin: bool,
|
|
65
|
+
cwd: str,
|
|
66
|
+
source: str | None = None,
|
|
67
|
+
) -> RunResult:
|
|
68
|
+
"""Runs a Python module via runpy (e.g. black, flake8).
|
|
69
|
+
|
|
70
|
+
Captures stdout, stderr, and SystemExit exit codes.
|
|
71
|
+
"""
|
|
72
|
+
with _cwd_lock(cwd):
|
|
73
|
+
str_output = CustomIO("<stdout>", encoding="utf-8")
|
|
74
|
+
str_error = CustomIO("<stderr>", encoding="utf-8")
|
|
75
|
+
|
|
76
|
+
exit_code = None
|
|
77
|
+
try:
|
|
78
|
+
with substitute_attr(sys, "argv", list(argv)):
|
|
79
|
+
with redirect_io("stdout", str_output):
|
|
80
|
+
with redirect_io("stderr", str_error):
|
|
81
|
+
if use_stdin:
|
|
82
|
+
str_input = CustomIO(
|
|
83
|
+
"<stdin>", encoding="utf-8", newline="\n"
|
|
84
|
+
)
|
|
85
|
+
with redirect_io("stdin", str_input):
|
|
86
|
+
if source is not None:
|
|
87
|
+
str_input.write(source)
|
|
88
|
+
str_input.seek(0)
|
|
89
|
+
runpy.run_module(module, run_name="__main__")
|
|
90
|
+
else:
|
|
91
|
+
runpy.run_module(module, run_name="__main__")
|
|
92
|
+
except SystemExit as e:
|
|
93
|
+
exit_code = e.code
|
|
94
|
+
|
|
95
|
+
return RunResult(str_output.get_value(), str_error.get_value(), exit_code)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def run_path(
|
|
99
|
+
argv: Sequence[str],
|
|
100
|
+
use_stdin: bool,
|
|
101
|
+
cwd: str,
|
|
102
|
+
source: str | None = None,
|
|
103
|
+
env: dict[str, str] | None = None,
|
|
104
|
+
timeout: float | None = None,
|
|
105
|
+
) -> RunResult:
|
|
106
|
+
"""Runs tool as a subprocess via executable path."""
|
|
107
|
+
if use_stdin:
|
|
108
|
+
with subprocess.Popen(
|
|
109
|
+
argv,
|
|
110
|
+
encoding="utf-8",
|
|
111
|
+
stdout=subprocess.PIPE,
|
|
112
|
+
stderr=subprocess.PIPE,
|
|
113
|
+
stdin=subprocess.PIPE,
|
|
114
|
+
cwd=cwd,
|
|
115
|
+
env=env,
|
|
116
|
+
) as process:
|
|
117
|
+
try:
|
|
118
|
+
stdout, stderr = process.communicate(input=source, timeout=timeout)
|
|
119
|
+
except subprocess.TimeoutExpired:
|
|
120
|
+
process.kill()
|
|
121
|
+
stdout, stderr = process.communicate()
|
|
122
|
+
return RunResult(stdout, stderr, process.returncode)
|
|
123
|
+
else:
|
|
124
|
+
try:
|
|
125
|
+
result = subprocess.run(
|
|
126
|
+
argv,
|
|
127
|
+
encoding="utf-8",
|
|
128
|
+
stdout=subprocess.PIPE,
|
|
129
|
+
stderr=subprocess.PIPE,
|
|
130
|
+
check=False,
|
|
131
|
+
cwd=cwd,
|
|
132
|
+
env=env,
|
|
133
|
+
timeout=timeout,
|
|
134
|
+
)
|
|
135
|
+
except subprocess.TimeoutExpired as e:
|
|
136
|
+
return RunResult(e.stdout or "", e.stderr or "", None)
|
|
137
|
+
return RunResult(result.stdout, result.stderr, result.returncode)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def run_api(
|
|
141
|
+
callback: Callable[[Sequence[str], CustomIO, CustomIO, CustomIO | None], None],
|
|
142
|
+
argv: Sequence[str],
|
|
143
|
+
use_stdin: bool,
|
|
144
|
+
cwd: str,
|
|
145
|
+
source: str | None = None,
|
|
146
|
+
) -> RunResult:
|
|
147
|
+
"""Runs tool via API callback (importable tools).
|
|
148
|
+
|
|
149
|
+
Captures stdout, stderr, and SystemExit exit codes.
|
|
150
|
+
"""
|
|
151
|
+
with _cwd_lock(cwd):
|
|
152
|
+
str_output = CustomIO("<stdout>", encoding="utf-8")
|
|
153
|
+
str_error = CustomIO("<stderr>", encoding="utf-8")
|
|
154
|
+
|
|
155
|
+
exit_code = None
|
|
156
|
+
try:
|
|
157
|
+
with substitute_attr(sys, "argv", list(argv)):
|
|
158
|
+
with redirect_io("stdout", str_output):
|
|
159
|
+
with redirect_io("stderr", str_error):
|
|
160
|
+
if use_stdin:
|
|
161
|
+
str_input = CustomIO(
|
|
162
|
+
"<stdin>", encoding="utf-8", newline="\n"
|
|
163
|
+
)
|
|
164
|
+
with redirect_io("stdin", str_input):
|
|
165
|
+
if source is not None:
|
|
166
|
+
str_input.write(source)
|
|
167
|
+
str_input.seek(0)
|
|
168
|
+
callback(argv, str_output, str_error, str_input)
|
|
169
|
+
else:
|
|
170
|
+
callback(argv, str_output, str_error, None)
|
|
171
|
+
except SystemExit as e:
|
|
172
|
+
exit_code = e.code
|
|
173
|
+
|
|
174
|
+
return RunResult(str_output.get_value(), str_error.get_value(), exit_code)
|