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,137 @@
1
+ # Copyright (c) Microsoft Corporation. All rights reserved.
2
+ # Licensed under the MIT License.
3
+ """Shared Python utilities for VS Code Python tool extensions."""
4
+
5
+ from .code_actions import (
6
+ QuickFixRegistrationError,
7
+ QuickFixRegistry,
8
+ command_quick_fix,
9
+ create_workspace_edit,
10
+ )
11
+ from .context import change_cwd, redirect_io, substitute_attr
12
+ from .debug import setup_debugpy
13
+ from .diagnostics import (
14
+ ParsedRecord,
15
+ get_severity,
16
+ make_diagnostic,
17
+ parse_diagnostics_regex,
18
+ parse_to_records,
19
+ records_to_diagnostics,
20
+ )
21
+ from .formatting import (
22
+ NOTEBOOK_CELL_SCHEME,
23
+ is_notebook_cell,
24
+ match_line_endings,
25
+ strip_trailing_newline,
26
+ )
27
+ from .jsonrpc import (
28
+ JsonRpc,
29
+ RpcRunResult,
30
+ StreamClosedException,
31
+ get_or_start_json_rpc,
32
+ run_over_json_rpc,
33
+ shutdown_json_rpc,
34
+ )
35
+ from .linting import LintRequestTracker
36
+ from .notebook import (
37
+ MAGIC_LINE_RE,
38
+ NOTEBOOK_SYNC_OPTIONS,
39
+ CellLike,
40
+ CellOffset,
41
+ SyntheticDocument,
42
+ build_notebook_source,
43
+ get_cell_for_line,
44
+ remap_diagnostics_to_cells,
45
+ )
46
+ from .paths import (
47
+ CWD_LOCK,
48
+ SERVER_CWD,
49
+ PythonFileKind,
50
+ as_list,
51
+ classify_python_file,
52
+ get_extensions_dir,
53
+ get_relative_path,
54
+ get_sys_config_paths,
55
+ is_current_interpreter,
56
+ is_match,
57
+ is_same_path,
58
+ normalize_path,
59
+ )
60
+ from .process_runner import run_message_loop, update_sys_path
61
+ from .runner import CustomIO, RunResult, run_api, run_module, run_path
62
+ from .server import ToolServer, ToolServerConfig
63
+ from .version import VersionInfo, check_min_version, extract_version, version_to_tuple
64
+
65
+ __all__ = [
66
+ # paths
67
+ "SERVER_CWD",
68
+ "CWD_LOCK",
69
+ "as_list",
70
+ "get_sys_config_paths",
71
+ "get_extensions_dir",
72
+ "get_relative_path",
73
+ "normalize_path",
74
+ "is_same_path",
75
+ "is_current_interpreter",
76
+ "classify_python_file",
77
+ "PythonFileKind",
78
+ "is_match",
79
+ # context
80
+ "substitute_attr",
81
+ "redirect_io",
82
+ "change_cwd",
83
+ # runner
84
+ "RunResult",
85
+ "CustomIO",
86
+ "run_module",
87
+ "run_path",
88
+ "run_api",
89
+ # jsonrpc
90
+ "StreamClosedException",
91
+ "JsonRpc",
92
+ "RpcRunResult",
93
+ "get_or_start_json_rpc",
94
+ "run_over_json_rpc",
95
+ "shutdown_json_rpc",
96
+ # process_runner
97
+ "update_sys_path",
98
+ "run_message_loop",
99
+ # debug
100
+ "setup_debugpy",
101
+ # server
102
+ "ToolServerConfig",
103
+ "ToolServer",
104
+ # code_actions
105
+ "QuickFixRegistrationError",
106
+ "QuickFixRegistry",
107
+ "command_quick_fix",
108
+ "create_workspace_edit",
109
+ # diagnostics
110
+ "ParsedRecord",
111
+ "get_severity",
112
+ "make_diagnostic",
113
+ "parse_diagnostics_regex",
114
+ "parse_to_records",
115
+ "records_to_diagnostics",
116
+ # formatting
117
+ "NOTEBOOK_CELL_SCHEME",
118
+ "match_line_endings",
119
+ "is_notebook_cell",
120
+ "strip_trailing_newline",
121
+ # linting
122
+ "LintRequestTracker",
123
+ # notebook
124
+ "CellLike",
125
+ "SyntheticDocument",
126
+ "CellOffset",
127
+ "MAGIC_LINE_RE",
128
+ "NOTEBOOK_SYNC_OPTIONS",
129
+ "build_notebook_source",
130
+ "get_cell_for_line",
131
+ "remap_diagnostics_to_cells",
132
+ # version
133
+ "VersionInfo",
134
+ "extract_version",
135
+ "check_min_version",
136
+ "version_to_tuple",
137
+ ]
@@ -0,0 +1,131 @@
1
+ # Copyright (c) Microsoft Corporation. All rights reserved.
2
+ # Licensed under the MIT License.
3
+ """Code action helpers shared by Python tool extensions.
4
+
5
+ Provides:
6
+ * :class:`QuickFixRegistry` — decorator-based registry for quick-fix
7
+ code actions (shared by flake8 and pylint).
8
+ * :func:`command_quick_fix` — build a command-based
9
+ :class:`lsprotocol.types.CodeAction`.
10
+ * :func:`create_workspace_edit` — build a
11
+ :class:`lsprotocol.types.WorkspaceEdit` for a single document.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import Callable
17
+ from typing import Any
18
+
19
+ import lsprotocol.types as lsp
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # QuickFix registry
23
+ # ---------------------------------------------------------------------------
24
+
25
+
26
+ class QuickFixRegistrationError(Exception):
27
+ """Raised when a quick-fix code is registered more than once."""
28
+
29
+ def __init__(self, code: str) -> None:
30
+ super().__init__(f"Quick fix already registered for code: {code!r}")
31
+ self.code = code
32
+
33
+
34
+ class QuickFixRegistry:
35
+ """Manages quick fixes registered using the :meth:`quick_fix` decorator.
36
+
37
+ Usage::
38
+
39
+ QUICK_FIXES = QuickFixRegistry()
40
+
41
+ @QUICK_FIXES.quick_fix(codes=["E226", "E227"])
42
+ def fix_format(document, diagnostics):
43
+ return [command_quick_fix(...)]
44
+ """
45
+
46
+ def __init__(self) -> None:
47
+ self._solutions: dict[
48
+ str,
49
+ Callable[[Any, list[lsp.Diagnostic]], list[lsp.CodeAction]],
50
+ ] = {}
51
+
52
+ def quick_fix(
53
+ self,
54
+ codes: str | list[str],
55
+ ) -> Callable[..., Any]:
56
+ """Decorator for registering quick fixes for one or more codes."""
57
+
58
+ def decorator(
59
+ func: Callable[[Any, list[lsp.Diagnostic]], list[lsp.CodeAction]],
60
+ ) -> Callable[[Any, list[lsp.Diagnostic]], list[lsp.CodeAction]]:
61
+ if isinstance(codes, str):
62
+ if codes in self._solutions:
63
+ raise QuickFixRegistrationError(codes)
64
+ self._solutions[codes] = func
65
+ else:
66
+ for code in codes:
67
+ if code in self._solutions:
68
+ raise QuickFixRegistrationError(code)
69
+ for code in codes:
70
+ self._solutions[code] = func
71
+ return func
72
+
73
+ return decorator
74
+
75
+ def solutions(
76
+ self,
77
+ code: str | None,
78
+ ) -> Callable[[Any, list[lsp.Diagnostic]], list[lsp.CodeAction]] | None:
79
+ """Return the quick-fix handler for *code*, or *None*."""
80
+ if code is None:
81
+ return None
82
+ return self._solutions.get(code)
83
+
84
+
85
+ # ---------------------------------------------------------------------------
86
+ # Helpers
87
+ # ---------------------------------------------------------------------------
88
+
89
+
90
+ def command_quick_fix(
91
+ diagnostics: list[lsp.Diagnostic],
92
+ title: str,
93
+ command: str,
94
+ args: list[Any] | None = None,
95
+ ) -> lsp.CodeAction:
96
+ """Build a command-based :class:`CodeAction`."""
97
+ return lsp.CodeAction(
98
+ title=title,
99
+ kind=lsp.CodeActionKind.QuickFix,
100
+ diagnostics=diagnostics,
101
+ command=lsp.Command(title=title, command=command, arguments=args),
102
+ )
103
+
104
+
105
+ def create_workspace_edit(
106
+ document_uri: str,
107
+ document_version: int | None,
108
+ text_edits: list[lsp.TextEdit],
109
+ ) -> lsp.WorkspaceEdit:
110
+ """Build a :class:`WorkspaceEdit` for a single document.
111
+
112
+ Parameters
113
+ ----------
114
+ document_uri:
115
+ The document's URI.
116
+ document_version:
117
+ The document's version (``None`` → 0 per LSP convention).
118
+ text_edits:
119
+ The edits to apply.
120
+ """
121
+ return lsp.WorkspaceEdit(
122
+ document_changes=[
123
+ lsp.TextDocumentEdit(
124
+ text_document=lsp.OptionalVersionedTextDocumentIdentifier(
125
+ uri=document_uri,
126
+ version=document_version if document_version is not None else 0,
127
+ ),
128
+ edits=text_edits,
129
+ )
130
+ ],
131
+ )
@@ -0,0 +1,61 @@
1
+ # Copyright (c) Microsoft Corporation. All rights reserved.
2
+ # Licensed under the MIT License.
3
+ """Context managers for use with running tools over LSP."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import contextlib
8
+ import logging
9
+ import os
10
+ import sys
11
+ from collections.abc import Iterator
12
+ from typing import Any
13
+
14
+ from .paths import SERVER_CWD
15
+
16
+
17
+ @contextlib.contextmanager
18
+ def substitute_attr(obj: object, attribute: str, new_value: Any) -> Iterator[None]:
19
+ """Manage object attributes context when using runpy.run_module()."""
20
+ old_value = getattr(obj, attribute)
21
+ setattr(obj, attribute, new_value)
22
+ try:
23
+ yield
24
+ finally:
25
+ setattr(obj, attribute, old_value)
26
+
27
+
28
+ @contextlib.contextmanager
29
+ def redirect_io(stream: str, new_stream: Any) -> Iterator[None]:
30
+ """Redirect stdio streams to a custom stream."""
31
+ old_stream = getattr(sys, stream)
32
+ setattr(sys, stream, new_stream)
33
+ try:
34
+ yield
35
+ finally:
36
+ setattr(sys, stream, old_stream)
37
+
38
+
39
+ @contextlib.contextmanager
40
+ def change_cwd(new_cwd: str) -> Iterator[None]:
41
+ """Change working directory before running code.
42
+
43
+ Always restores to ``SERVER_CWD`` (the working directory at process
44
+ start), not the working directory active when the context manager was
45
+ entered. This matches the behaviour of all upstream extension repos.
46
+ """
47
+ try:
48
+ os.chdir(new_cwd)
49
+ except OSError as e:
50
+ logging.warning(
51
+ "Failed to change directory to %r, running in %r instead: %s",
52
+ new_cwd,
53
+ SERVER_CWD,
54
+ e,
55
+ )
56
+ yield
57
+ return
58
+ try:
59
+ yield
60
+ finally:
61
+ os.chdir(SERVER_CWD)
@@ -0,0 +1,49 @@
1
+ # Copyright (c) Microsoft Corporation. All rights reserved.
2
+ # Licensed under the MIT License.
3
+ """Debugging support for LSP."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ import pathlib
9
+
10
+ from .process_runner import update_sys_path
11
+
12
+
13
+ def setup_debugpy(port: int = 5678, *, require_opt_in: bool = True) -> None:
14
+ """Conditionally attach debugpy if the environment is configured.
15
+
16
+ Checks the ``DEBUGPY_PATH`` environment variable and, when present,
17
+ loads debugpy from that path and connects to the given *port*.
18
+
19
+ Parameters
20
+ ----------
21
+ port:
22
+ The port to connect to for debugging.
23
+ require_opt_in:
24
+ When ``True`` (default), the ``USE_DEBUGPY`` environment variable
25
+ must also be set to a truthy value (``True``, ``TRUE``, ``1``, or
26
+ ``T``). Set to ``False`` to skip this check — useful for
27
+ extensions that don't gate on ``USE_DEBUGPY`` (e.g. flake8, mypy).
28
+ """
29
+ if require_opt_in and os.getenv("USE_DEBUGPY", None) not in (
30
+ "True",
31
+ "TRUE",
32
+ "1",
33
+ "T",
34
+ ):
35
+ return
36
+
37
+ debugger_path = os.getenv("DEBUGPY_PATH", None)
38
+ if not debugger_path:
39
+ return
40
+
41
+ if pathlib.Path(debugger_path).name == "debugpy":
42
+ debugger_path = os.fspath(pathlib.Path(debugger_path).parent)
43
+
44
+ update_sys_path(debugger_path, "fromEnvironment")
45
+
46
+ import debugpy # noqa: E402
47
+
48
+ debugpy.connect(port)
49
+ debugpy.breakpoint()
@@ -0,0 +1,275 @@
1
+ # Copyright (c) Microsoft Corporation. All rights reserved.
2
+ # Licensed under the MIT License.
3
+ """Diagnostic construction and parsing helpers for LSP tool extensions.
4
+
5
+ Provides:
6
+ * Severity resolution shared by flake8, isort, mypy and pylint.
7
+ * A generic regex-based diagnostic parser used by flake8 and mypy (with
8
+ a file-aware callback to support mypy's multi-file output).
9
+ * A helper to build individual :class:`lsprotocol.types.Diagnostic` objects.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import dataclasses
15
+ import re
16
+ from collections.abc import Callable
17
+ from typing import Any
18
+
19
+ import lsprotocol.types as lsp
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Severity resolution
23
+ # ---------------------------------------------------------------------------
24
+
25
+
26
+ def get_severity(
27
+ code: str,
28
+ code_type: str,
29
+ severity_map: dict[str, str],
30
+ *,
31
+ default: str = "Error",
32
+ symbol: str | None = None,
33
+ ) -> lsp.DiagnosticSeverity:
34
+ """Resolve an LSP :class:`DiagnosticSeverity` from tool output.
35
+
36
+ Lookup order (matching pylint's most-expressive pattern):
37
+ ``symbol`` → ``code`` → ``code_type`` → *default*.
38
+
39
+ Parameters
40
+ ----------
41
+ code:
42
+ Machine-readable error code (e.g. ``"E501"``, ``"C0301"``).
43
+ code_type:
44
+ Category/type string (e.g. ``"Error"``, ``"convention"``).
45
+ severity_map:
46
+ User-configurable mapping of codes/types → severity names.
47
+ default:
48
+ Fallback severity name when nothing matches.
49
+ symbol:
50
+ Optional symbolic name (e.g. ``"line-too-long"``). Only pylint
51
+ exposes this; other tools pass *None*.
52
+ """
53
+ value = (
54
+ (symbol and severity_map.get(symbol))
55
+ or severity_map.get(code, None)
56
+ or severity_map.get(code_type, None)
57
+ or default
58
+ )
59
+ try:
60
+ return lsp.DiagnosticSeverity[value]
61
+ except KeyError:
62
+ return lsp.DiagnosticSeverity.Error
63
+
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # Diagnostic construction
67
+ # ---------------------------------------------------------------------------
68
+
69
+
70
+ def make_diagnostic(
71
+ *,
72
+ line: int,
73
+ column: int,
74
+ message: str,
75
+ severity: lsp.DiagnosticSeverity,
76
+ code: str = "",
77
+ source: str = "",
78
+ end_line: int | None = None,
79
+ end_column: int | None = None,
80
+ code_description: lsp.CodeDescription | None = None,
81
+ data: Any = None,
82
+ ) -> lsp.Diagnostic:
83
+ """Build an :class:`lsprotocol.types.Diagnostic`.
84
+
85
+ *line* and *column* are **0-based** (LSP convention). If *end_line*
86
+ or *end_column* are *None* the range degenerates to a point.
87
+ """
88
+ start = lsp.Position(line=line, character=column)
89
+ end = lsp.Position(
90
+ line=end_line if end_line is not None else line,
91
+ character=end_column if end_column is not None else column,
92
+ )
93
+ return lsp.Diagnostic(
94
+ range=lsp.Range(start=start, end=end),
95
+ message=message,
96
+ severity=severity,
97
+ code=code or None,
98
+ code_description=code_description,
99
+ source=source or None,
100
+ data=data,
101
+ )
102
+
103
+
104
+ # ---------------------------------------------------------------------------
105
+ # Regex-based diagnostic parsing
106
+ # ---------------------------------------------------------------------------
107
+
108
+
109
+ @dataclasses.dataclass(slots=True)
110
+ class ParsedRecord:
111
+ """Intermediate representation of a single parsed diagnostic line.
112
+
113
+ Provides typed access to the regex-matched groups before they are
114
+ turned into :class:`lsprotocol.types.Diagnostic` objects.
115
+ """
116
+
117
+ file: str = ""
118
+ line: int = 0
119
+ column: int = 0
120
+ code: str = ""
121
+ code_type: str = ""
122
+ message: str = ""
123
+
124
+
125
+ def parse_to_records(
126
+ content: str,
127
+ pattern: re.Pattern[str],
128
+ *,
129
+ line_at_1: bool = True,
130
+ col_at_1: bool = True,
131
+ ) -> list[ParsedRecord]:
132
+ """Parse tool output into :class:`ParsedRecord` instances.
133
+
134
+ This is the first stage of the two-stage parsing pipeline. Use
135
+ :func:`records_to_diagnostics` to convert the records into LSP
136
+ diagnostics, or process them directly for custom handling (e.g. mypy
137
+ note-chaining).
138
+
139
+ The *pattern* uses ``re.match`` semantics (anchored at the start of
140
+ each line). If your tool output may contain leading whitespace or
141
+ prefixes, account for that in the pattern.
142
+
143
+ Lines where the ``line`` named group is missing or not parseable as an
144
+ integer are skipped.
145
+ """
146
+ records: list[ParsedRecord] = []
147
+ line_offset = 1 if line_at_1 else 0
148
+ col_offset = 1 if col_at_1 else 0
149
+
150
+ for line in content.splitlines():
151
+ match = pattern.match(line)
152
+ if not match:
153
+ continue
154
+
155
+ data = match.groupdict()
156
+ line_value = data.get("line")
157
+ if not line_value:
158
+ continue
159
+
160
+ try:
161
+ parsed_line = int(line_value)
162
+ except ValueError:
163
+ continue
164
+
165
+ records.append(
166
+ ParsedRecord(
167
+ file=data.get("file") or "",
168
+ line=max(parsed_line - line_offset, 0),
169
+ column=max(int(data.get("column") or "1") - col_offset, 0),
170
+ code=data.get("code") or "",
171
+ code_type=data.get("type") or "",
172
+ message=data.get("message") or "",
173
+ )
174
+ )
175
+
176
+ return records
177
+
178
+
179
+ def records_to_diagnostics(
180
+ records: list[ParsedRecord],
181
+ severity_map: dict[str, str],
182
+ source: str,
183
+ *,
184
+ default_severity: str = "Error",
185
+ ) -> list[lsp.Diagnostic]:
186
+ """Convert :class:`ParsedRecord` instances into LSP diagnostics.
187
+
188
+ This is the second stage of the two-stage parsing pipeline.
189
+ """
190
+ diagnostics: list[lsp.Diagnostic] = []
191
+ for record in records:
192
+ severity = get_severity(
193
+ record.code,
194
+ record.code_type,
195
+ severity_map,
196
+ default=default_severity,
197
+ )
198
+ diagnostics.append(
199
+ make_diagnostic(
200
+ line=record.line,
201
+ column=record.column,
202
+ message=record.message,
203
+ severity=severity,
204
+ code=record.code,
205
+ source=source,
206
+ )
207
+ )
208
+ return diagnostics
209
+
210
+
211
+ def parse_diagnostics_regex(
212
+ content: str,
213
+ pattern: re.Pattern[str],
214
+ severity_map: dict[str, str],
215
+ source: str,
216
+ *,
217
+ line_at_1: bool = True,
218
+ col_at_1: bool = True,
219
+ default_severity: str = "Error",
220
+ record_callback: Callable[[ParsedRecord], lsp.Diagnostic | None] | None = None,
221
+ ) -> list[lsp.Diagnostic]:
222
+ """Parse tool output into diagnostics using a compiled regex.
223
+
224
+ The *pattern* must use **named groups** and ``re.match`` semantics
225
+ (anchored at the start of each line). If your tool output may contain
226
+ leading whitespace or prefixes, account for that in the pattern.
227
+
228
+ Required named groups:
229
+
230
+ * ``line`` — 1-based line number (lines without a valid integer are
231
+ skipped)
232
+
233
+ Optional named groups:
234
+
235
+ * ``column`` — 1-based column number
236
+ * ``type`` — severity category (``Error``, ``Warning``, …)
237
+ * ``code`` — machine-readable code
238
+ * ``message`` — human-readable message
239
+ * ``file`` — file path (for multi-file output like mypy)
240
+
241
+ Parameters
242
+ ----------
243
+ content:
244
+ Raw tool stdout to parse.
245
+ pattern:
246
+ Compiled regex with named groups.
247
+ severity_map:
248
+ User severity overrides.
249
+ source:
250
+ Value for :attr:`Diagnostic.source` (e.g. ``"Flake8"``).
251
+ line_at_1:
252
+ Whether the tool's line numbers are 1-based (subtract 1 for LSP).
253
+ col_at_1:
254
+ Whether the tool's column numbers are 1-based.
255
+ default_severity:
256
+ Fallback severity when neither code nor type matches.
257
+ record_callback:
258
+ Optional callback that receives each :class:`ParsedRecord` and
259
+ returns a :class:`Diagnostic` (or *None* to skip). When provided
260
+ the default diagnostic construction is bypassed — use this for
261
+ tools like mypy that need note-chaining or custom ranges.
262
+ """
263
+ records = parse_to_records(content, pattern, line_at_1=line_at_1, col_at_1=col_at_1)
264
+
265
+ if record_callback is not None:
266
+ diagnostics: list[lsp.Diagnostic] = []
267
+ for record in records:
268
+ diag = record_callback(record)
269
+ if diag is not None:
270
+ diagnostics.append(diag)
271
+ return diagnostics
272
+
273
+ return records_to_diagnostics(
274
+ records, severity_map, source, default_severity=default_severity
275
+ )
@@ -0,0 +1,48 @@
1
+ # Copyright (c) Microsoft Corporation. All rights reserved.
2
+ # Licensed under the MIT License.
3
+ """Formatting helpers shared by Python tool extensions."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from urllib.parse import urlparse
8
+
9
+ NOTEBOOK_CELL_SCHEME = "vscode-notebook-cell"
10
+
11
+
12
+ def _get_line_endings(lines: list[str]) -> str | None:
13
+ """Detect the dominant line ending in *lines*.
14
+
15
+ Returns ``"\\r\\n"`` or ``"\\n"`` (or *None* when indeterminate).
16
+ """
17
+ crlf = sum(1 for line in lines if line.endswith("\r\n"))
18
+ lf = sum(1 for line in lines if line.endswith("\n") and not line.endswith("\r\n"))
19
+ if crlf == 0 and lf == 0:
20
+ return None
21
+ return "\r\n" if crlf > lf else "\n"
22
+
23
+
24
+ def match_line_endings(document_source: str, edited_text: str) -> str:
25
+ """Ensure *edited_text* uses the same line endings as *document_source*.
26
+
27
+ This is identical across all 5 extension repos and prevents spurious
28
+ diffs when the tool normalises line endings differently from the editor.
29
+ """
30
+ expected = _get_line_endings(document_source.splitlines(keepends=True))
31
+ actual = _get_line_endings(edited_text.splitlines(keepends=True))
32
+ if actual == expected or actual is None or expected is None:
33
+ return edited_text
34
+ return edited_text.replace(actual, expected)
35
+
36
+
37
+ def is_notebook_cell(uri: str) -> bool:
38
+ """Return *True* if *uri* refers to a notebook cell."""
39
+ return urlparse(uri).scheme == NOTEBOOK_CELL_SCHEME
40
+
41
+
42
+ def strip_trailing_newline(text: str) -> str:
43
+ """Strip a single trailing newline (for notebook cell formatting)."""
44
+ if text.endswith("\r\n"):
45
+ return text[:-2]
46
+ if text.endswith("\n"):
47
+ return text[:-1]
48
+ return text