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,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
|