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.
@@ -0,0 +1,306 @@
1
+ # Copyright (c) Microsoft Corporation. All rights reserved.
2
+ # Licensed under the MIT License.
3
+ """Light-weight JSON-RPC over standard IO."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import atexit
8
+ import contextlib
9
+ import json
10
+ import os
11
+ import subprocess
12
+ import threading
13
+ import uuid
14
+ from collections.abc import Sequence
15
+ from concurrent.futures import ThreadPoolExecutor
16
+ from dataclasses import dataclass
17
+ from typing import BinaryIO
18
+
19
+ CONTENT_LENGTH = "Content-Length: "
20
+
21
+
22
+ class StreamClosedException(Exception):
23
+ """JSON RPC stream is closed."""
24
+
25
+ pass
26
+
27
+
28
+ class JsonRpc:
29
+ """Manages sending and receiving data over JSON-RPC.
30
+
31
+ Handles content-length-framed JSON messages over a pair of binary
32
+ streams (typically ``stdin``/``stdout`` of a subprocess).
33
+ """
34
+
35
+ def __init__(self, reader: BinaryIO, writer: BinaryIO):
36
+ self._reader = reader
37
+ self._writer = writer
38
+ self._write_lock = threading.Lock()
39
+
40
+ # -- write --------------------------------------------------------------
41
+
42
+ def write(self, data: object) -> None:
43
+ """Write *data* to the stream in JSON-RPC format (thread-safe)."""
44
+ with self._write_lock:
45
+ if self._writer.closed:
46
+ raise StreamClosedException()
47
+ content = json.dumps(data)
48
+ length = len(content.encode("utf-8"))
49
+ self._writer.write(
50
+ f"{CONTENT_LENGTH}{length}\r\n\r\n{content}".encode("utf-8")
51
+ )
52
+ self._writer.flush()
53
+
54
+ def send_data(self, data: object) -> None:
55
+ """Alias for :meth:`write` — send *data* in JSON-RPC format."""
56
+ self.write(data)
57
+
58
+ # -- read ---------------------------------------------------------------
59
+
60
+ def read(self) -> dict[str, object]:
61
+ """Read and return the next JSON-RPC message from the stream."""
62
+ if self._reader.closed:
63
+ raise StreamClosedException()
64
+ length = None
65
+ while not length:
66
+ line = self._readline().decode("utf-8")
67
+ if line.startswith(CONTENT_LENGTH):
68
+ length = int(line[len(CONTENT_LENGTH) :]) # noqa: E203
69
+
70
+ line = self._readline().decode("utf-8").strip()
71
+ while line:
72
+ line = self._readline().decode("utf-8").strip()
73
+
74
+ content = self._reader.read(length).decode("utf-8")
75
+ return json.loads(content)
76
+
77
+ def receive_data(self) -> dict[str, object]:
78
+ """Alias for :meth:`read` — receive data in JSON-RPC format."""
79
+ return self.read()
80
+
81
+ # -- close --------------------------------------------------------------
82
+
83
+ def close(self) -> None:
84
+ """Close both the reader and writer streams."""
85
+ with contextlib.suppress(Exception):
86
+ if not self._reader.closed:
87
+ self._reader.close()
88
+ with self._write_lock:
89
+ if not self._writer.closed:
90
+ self._writer.close()
91
+
92
+ # -- internal -----------------------------------------------------------
93
+
94
+ def _readline(self) -> bytes:
95
+ line = self._reader.readline()
96
+ if not line:
97
+ raise EOFError
98
+ return line
99
+
100
+
101
+ # ---------------------------------------------------------------------------
102
+ # Process management
103
+ # ---------------------------------------------------------------------------
104
+
105
+
106
+ class ProcessManager:
107
+ """Manages sub-processes launched for running tools."""
108
+
109
+ def __init__(self):
110
+ self._processes: dict[str, subprocess.Popen] = {}
111
+ self._rpc: dict[str, JsonRpc] = {}
112
+ self._lock = threading.Lock()
113
+ self._thread_pool = ThreadPoolExecutor(10)
114
+
115
+ def stop_process(self, workspace: str) -> None:
116
+ """Stop the process for the given workspace."""
117
+ with self._lock:
118
+ if workspace in self._processes:
119
+ proc = self._processes[workspace]
120
+ try:
121
+ proc.kill()
122
+ proc.wait(timeout=5)
123
+ except (OSError, subprocess.TimeoutExpired):
124
+ pass
125
+ del self._processes[workspace]
126
+ if workspace in self._rpc:
127
+ rpc = self._rpc.pop(workspace)
128
+ with contextlib.suppress(Exception):
129
+ rpc.close()
130
+
131
+ def stop_all_processes(self) -> None:
132
+ """Stop all managed processes and shutdown transport."""
133
+ with self._lock:
134
+ rpcs = list(self._rpc.values())
135
+ procs = list(self._processes.values())
136
+ self._rpc.clear()
137
+ self._processes.clear()
138
+ for rpc in rpcs:
139
+ with contextlib.suppress(Exception):
140
+ rpc.send_data({"id": str(uuid.uuid4()), "method": "exit"})
141
+ with contextlib.suppress(Exception):
142
+ rpc.close()
143
+ for proc in procs:
144
+ with contextlib.suppress(Exception):
145
+ proc.kill()
146
+ proc.wait(timeout=5)
147
+ self._thread_pool.shutdown(wait=False)
148
+
149
+ def start_process(
150
+ self,
151
+ workspace: str,
152
+ args: Sequence[str],
153
+ cwd: str,
154
+ env: dict[str, str] | None = None,
155
+ ) -> None:
156
+ """Starts a process and establishes JSON-RPC communication over stdio."""
157
+ new_env = os.environ.copy()
158
+ if env is not None:
159
+ new_env.update(env)
160
+ proc = subprocess.Popen(
161
+ args,
162
+ cwd=cwd,
163
+ stdout=subprocess.PIPE,
164
+ stdin=subprocess.PIPE,
165
+ env=new_env,
166
+ )
167
+ with self._lock:
168
+ self._processes[workspace] = proc
169
+ self._rpc[workspace] = JsonRpc(proc.stdout, proc.stdin)
170
+
171
+ def _monitor_process():
172
+ proc.wait()
173
+ with self._lock:
174
+ # Only clean up if this is still the registered process
175
+ # (a replacement may have been started under the same key).
176
+ if self._processes.get(workspace) is not proc:
177
+ return
178
+ self._processes.pop(workspace, None)
179
+ rpc = self._rpc.pop(workspace, None)
180
+ if rpc:
181
+ rpc.close()
182
+
183
+ self._thread_pool.submit(_monitor_process)
184
+
185
+ def get_json_rpc(self, workspace: str) -> JsonRpc:
186
+ """Gets the JSON-RPC wrapper for a given workspace id."""
187
+ with self._lock:
188
+ if workspace in self._rpc:
189
+ return self._rpc[workspace]
190
+ raise StreamClosedException()
191
+
192
+
193
+ _process_manager = ProcessManager()
194
+ _start_lock = threading.Lock()
195
+ atexit.register(_process_manager.stop_all_processes)
196
+
197
+
198
+ def _get_json_rpc(workspace: str) -> JsonRpc | None:
199
+ try:
200
+ return _process_manager.get_json_rpc(workspace)
201
+ except (StreamClosedException, KeyError):
202
+ return None
203
+
204
+
205
+ def get_or_start_json_rpc(
206
+ workspace: str,
207
+ interpreter: Sequence[str],
208
+ cwd: str,
209
+ runner_script: str,
210
+ env: dict[str, str] | None = None,
211
+ ) -> JsonRpc | None:
212
+ """Gets an existing JSON-RPC connection or starts one and return it."""
213
+ with _start_lock:
214
+ res = _get_json_rpc(workspace)
215
+ if not res:
216
+ args = [*interpreter, runner_script]
217
+ _process_manager.start_process(workspace, args, cwd, env)
218
+ res = _get_json_rpc(workspace)
219
+ return res
220
+
221
+
222
+ # ---------------------------------------------------------------------------
223
+ # RPC run helpers
224
+ # ---------------------------------------------------------------------------
225
+
226
+
227
+ @dataclass
228
+ class RpcRunResult:
229
+ """Object to hold result from running tool over RPC."""
230
+
231
+ stdout: str
232
+ stderr: str
233
+ exception: str | None = None
234
+
235
+
236
+ def run_over_json_rpc(
237
+ workspace: str,
238
+ interpreter: Sequence[str],
239
+ module: str,
240
+ argv: Sequence[str],
241
+ use_stdin: bool,
242
+ cwd: str,
243
+ runner_script: str,
244
+ source: str | None = None,
245
+ env: dict[str, str] | None = None,
246
+ timeout: float | None = None,
247
+ ) -> RpcRunResult:
248
+ """Uses JSON-RPC to execute a command."""
249
+ rpc = get_or_start_json_rpc(workspace, interpreter, cwd, runner_script, env)
250
+ if not rpc:
251
+ raise ConnectionError("Failed to run over JSON-RPC.")
252
+
253
+ msg_id = str(uuid.uuid4())
254
+ msg = {
255
+ "id": msg_id,
256
+ "method": "run",
257
+ "module": module,
258
+ "argv": argv,
259
+ "useStdin": use_stdin,
260
+ "cwd": cwd,
261
+ }
262
+ if source is not None:
263
+ msg["source"] = source
264
+
265
+ rpc.send_data(msg)
266
+
267
+ if timeout is not None:
268
+ result_container: list[object] = [None]
269
+ error_container: list[BaseException | None] = [None]
270
+
271
+ def _receive():
272
+ try:
273
+ result_container[0] = rpc.receive_data()
274
+ except Exception as e:
275
+ error_container[0] = e
276
+
277
+ recv_thread = threading.Thread(target=_receive, daemon=True)
278
+ recv_thread.start()
279
+ recv_thread.join(timeout)
280
+ if recv_thread.is_alive():
281
+ _process_manager.stop_process(workspace)
282
+ raise TimeoutError(f"JSON-RPC call timed out after {timeout}s")
283
+ if error_container[0] is not None:
284
+ raise error_container[0]
285
+ data = result_container[0]
286
+ else:
287
+ data = rpc.receive_data()
288
+
289
+ if data["id"] != msg_id:
290
+ return RpcRunResult(
291
+ "", f"Invalid result for request: {json.dumps(msg, indent=4)}"
292
+ )
293
+
294
+ result = data.get("result", "")
295
+ if "error" in data:
296
+ error = data["error"]
297
+ if data.get("exception", False):
298
+ return RpcRunResult(result, "", error)
299
+ return RpcRunResult(result, error)
300
+
301
+ return RpcRunResult(result, "")
302
+
303
+
304
+ def shutdown_json_rpc():
305
+ """Shutdown all JSON-RPC processes."""
306
+ _process_manager.stop_all_processes()
@@ -0,0 +1,62 @@
1
+ # Copyright (c) Microsoft Corporation. All rights reserved.
2
+ # Licensed under the MIT License.
3
+ """Lint request tracking for stale-result deduplication.
4
+
5
+ When multiple ``didSave`` events fire in quick succession each one spawns
6
+ a tool process. Only the **latest** result should be published — earlier
7
+ runs are stale. This module provides :class:`LintRequestTracker` which
8
+ manages a per-URI version counter protected by a threading lock.
9
+
10
+ Shared by flake8, mypy, and pylint (formatters don't need this).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import threading
16
+
17
+
18
+ class LintRequestTracker:
19
+ """Thread-safe version counter for lint request deduplication.
20
+
21
+ Usage::
22
+
23
+ tracker = LintRequestTracker()
24
+
25
+ # At the start of a lint run:
26
+ version = tracker.increment(uri)
27
+
28
+ # ... run tool, parse output ...
29
+
30
+ # Before publishing diagnostics:
31
+ if tracker.is_current(uri, version):
32
+ publish_diagnostics(...)
33
+ """
34
+
35
+ def __init__(self) -> None:
36
+ self._versions: dict[str, int] = {}
37
+ self._lock = threading.Lock()
38
+
39
+ def increment(self, uri: str) -> int:
40
+ """Bump and return the version for *uri*."""
41
+ with self._lock:
42
+ version = self._versions.get(uri, 0) + 1
43
+ self._versions[uri] = version
44
+ return version
45
+
46
+ def is_current(self, uri: str, version: int) -> bool:
47
+ """Return *True* if *version* is still the latest for *uri*."""
48
+ with self._lock:
49
+ if uri not in self._versions:
50
+ return False
51
+ return self._versions[uri] == version
52
+
53
+ def reset(self, uri: str | None = None) -> None:
54
+ """Reset version counters.
55
+
56
+ When *uri* is ``None`` all counters are cleared.
57
+ """
58
+ with self._lock:
59
+ if uri is None:
60
+ self._versions.clear()
61
+ else:
62
+ self._versions.pop(uri, None)
@@ -0,0 +1,245 @@
1
+ # Copyright (c) Microsoft Corporation. All rights reserved.
2
+ # Licensed under the MIT License.
3
+ """Notebook-specific helpers for whole-notebook linting with cross-cell context.
4
+
5
+ Provides:
6
+ * :class:`SyntheticDocument` — a typed stand-in for ``TextDocument`` used
7
+ when linting a combined notebook source.
8
+ * :func:`build_notebook_source` — concatenates all code cells into one
9
+ string, replacing IPython magic lines with ``pass``.
10
+ * :func:`remap_diagnostics_to_cells` — maps combined-source diagnostics
11
+ back to individual cell URIs.
12
+ * :data:`NOTEBOOK_SYNC_OPTIONS` — default ``NotebookDocumentSyncOptions``
13
+ shared by all tool extensions.
14
+
15
+ This module is identical across flake8, isort, and pylint (black uses a
16
+ simpler variant).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import dataclasses
22
+ import re
23
+ from collections.abc import Callable, Sequence
24
+ from typing import Any, Protocol
25
+
26
+ import lsprotocol.types as lsp
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Protocols & types
30
+ # ---------------------------------------------------------------------------
31
+
32
+
33
+ class TextDocumentLike(Protocol):
34
+ """Protocol for objects that provide text document attributes.
35
+
36
+ Any object with ``source`` and ``language_id`` string attributes
37
+ satisfies this — including ``pygls.workspace.TextDocument``.
38
+ """
39
+
40
+ source: str
41
+ language_id: str
42
+
43
+
44
+ class CellLike(Protocol):
45
+ """Protocol for notebook cell objects.
46
+
47
+ Any object with ``kind`` and ``document`` attributes satisfies this —
48
+ including ``lsprotocol.types.NotebookCell``.
49
+ """
50
+
51
+ kind: Any
52
+ document: str | None
53
+
54
+
55
+ @dataclasses.dataclass
56
+ class SyntheticDocument:
57
+ """Typed stand-in for ``workspace.TextDocument`` used in notebook linting.
58
+
59
+ Replaces ``types.SimpleNamespace`` so that the synthetic document has
60
+ an explicit, portable shape that can be type-checked.
61
+ """
62
+
63
+ uri: str
64
+ path: str
65
+ source: str
66
+ language_id: str = "python"
67
+ version: int = 0
68
+
69
+
70
+ # Matches IPython magic lines (%, %%, !, !!) so they can be replaced with ``pass``.
71
+ MAGIC_LINE_RE = re.compile(r"^\s*(?:%%\w|%(?!=)\w|!!|!(?!=)\w)")
72
+
73
+ # Maximum character value in LSP (the LSP ``uinteger`` max per the spec).
74
+ # https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#uinteger
75
+ MAX_LSP_CHARACTER = 2_147_483_647
76
+
77
+ NOTEBOOK_SYNC_OPTIONS = lsp.NotebookDocumentSyncOptions(
78
+ notebook_selector=[
79
+ lsp.NotebookDocumentFilterWithNotebook(
80
+ notebook="jupyter-notebook",
81
+ cells=[
82
+ lsp.NotebookCellLanguage(language="python"),
83
+ ],
84
+ ),
85
+ lsp.NotebookDocumentFilterWithNotebook(
86
+ notebook="interactive",
87
+ cells=[
88
+ lsp.NotebookCellLanguage(language="python"),
89
+ ],
90
+ ),
91
+ ],
92
+ save=True,
93
+ )
94
+
95
+
96
+ @dataclasses.dataclass
97
+ class CellOffset:
98
+ """Describes where a single notebook cell's lines begin in the combined source."""
99
+
100
+ cell_uri: str
101
+ start_line: int
102
+ line_count: int
103
+
104
+
105
+ def build_notebook_source(
106
+ cells: Sequence[CellLike],
107
+ get_text_document: Callable[[str], TextDocumentLike | None],
108
+ *,
109
+ sanitize_line: Callable[[str], str] | None = None,
110
+ ) -> tuple[str, list[CellOffset]]:
111
+ """Build a single Python source string from all code cells.
112
+
113
+ Parameters
114
+ ----------
115
+ cells:
116
+ The notebook's cell list (``nb.cells``).
117
+ get_text_document:
118
+ A callable that resolves a cell document URI to a text document
119
+ object (with ``.source`` and ``.language_id`` attributes).
120
+ sanitize_line:
121
+ Optional per-line transformation. When *None* the default
122
+ behaviour replaces IPython magic lines with ``pass\\n``. Pass a
123
+ custom callable to override (e.g. for tool-specific magic handling).
124
+
125
+ Returns
126
+ -------
127
+ (combined_source, cell_map) where *cell_map* is a list of
128
+ :class:`CellOffset` instances describing where each cell's lines
129
+ begin in the combined source.
130
+ """
131
+ _sanitize = sanitize_line or _default_sanitize_line
132
+
133
+ source_parts: list[str] = []
134
+ cell_map: list[CellOffset] = []
135
+ current_line = 0
136
+
137
+ for cell in cells:
138
+ if cell.kind != lsp.NotebookCellKind.Code or cell.document is None:
139
+ continue
140
+ doc = get_text_document(cell.document)
141
+ if doc is None or doc.language_id != "python":
142
+ continue
143
+
144
+ source = doc.source
145
+ if not source:
146
+ continue
147
+
148
+ lines = source.splitlines(keepends=True)
149
+ # Ensure the last line ends with a newline.
150
+ if lines and not lines[-1].endswith("\n"):
151
+ lines[-1] += "\n"
152
+
153
+ sanitized_lines = [_sanitize(line) for line in lines]
154
+
155
+ cell_map.append(CellOffset(cell.document, current_line, len(sanitized_lines)))
156
+ source_parts.extend(sanitized_lines)
157
+ current_line += len(sanitized_lines)
158
+
159
+ return "".join(source_parts), cell_map
160
+
161
+
162
+ def _default_sanitize_line(line: str) -> str:
163
+ """Replace IPython magic lines with ``pass`` while preserving line endings."""
164
+ if not MAGIC_LINE_RE.match(line):
165
+ return line
166
+ if line.endswith("\r\n"):
167
+ return "pass\r\n"
168
+ if line.endswith("\n"):
169
+ return "pass\n"
170
+ return "pass"
171
+
172
+
173
+ def get_cell_for_line(
174
+ global_line: int, cell_map: list[CellOffset]
175
+ ) -> CellOffset | None:
176
+ """Return the :class:`CellOffset` entry that owns *global_line*.
177
+
178
+ *global_line* is a 0-based line number in the combined notebook source.
179
+ Returns ``None`` if no cell owns the line.
180
+ """
181
+ for entry in cell_map:
182
+ if entry.start_line <= global_line < entry.start_line + entry.line_count:
183
+ return entry
184
+ return None
185
+
186
+
187
+ def remap_diagnostics_to_cells(
188
+ diagnostics: Sequence[lsp.Diagnostic],
189
+ cell_map: list[CellOffset],
190
+ ) -> dict[str, list[lsp.Diagnostic]]:
191
+ """Map combined-source diagnostics back to individual cell URIs.
192
+
193
+ Each diagnostic's line range is adjusted relative to the owning cell.
194
+ Diagnostics whose start line doesn't fall in any cell are discarded.
195
+ If a diagnostic's end line crosses a cell boundary it is clamped.
196
+ """
197
+ per_cell: dict[str, list[lsp.Diagnostic]] = {
198
+ entry.cell_uri: [] for entry in cell_map
199
+ }
200
+
201
+ for diag in diagnostics:
202
+ entry = get_cell_for_line(diag.range.start.line, cell_map)
203
+ if entry is None:
204
+ continue
205
+
206
+ local_start_line = diag.range.start.line - entry.start_line
207
+ local_start = lsp.Position(
208
+ line=local_start_line,
209
+ character=diag.range.start.character,
210
+ )
211
+
212
+ # Clamp end line to the cell boundary (defensive).
213
+ max_end_line = entry.line_count - 1
214
+ raw_end_line = diag.range.end.line - entry.start_line
215
+ clamped = raw_end_line > max_end_line
216
+ local_end_line = min(raw_end_line, max_end_line)
217
+ # Use max LSP uint32 value to cover the full last line.
218
+ local_end = lsp.Position(
219
+ line=local_end_line,
220
+ character=MAX_LSP_CHARACTER if clamped else diag.range.end.character,
221
+ )
222
+
223
+ # Ensure end is not before start (inverted range violates LSP spec).
224
+ if (
225
+ local_end.line == local_start.line
226
+ and local_end.character < local_start.character
227
+ ):
228
+ local_end = lsp.Position(
229
+ line=local_start.line, character=local_start.character
230
+ )
231
+
232
+ remapped = lsp.Diagnostic(
233
+ range=lsp.Range(start=local_start, end=local_end),
234
+ message=diag.message,
235
+ severity=diag.severity,
236
+ code=diag.code,
237
+ code_description=diag.code_description,
238
+ source=diag.source,
239
+ related_information=diag.related_information,
240
+ tags=diag.tags,
241
+ data=diag.data,
242
+ )
243
+ per_cell[entry.cell_uri].append(remapped)
244
+
245
+ return per_cell