vscode-common-python-lsp 0.1.0__tar.gz
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-0.1.0/PKG-INFO +22 -0
- vscode_common_python_lsp-0.1.0/README.md +5 -0
- vscode_common_python_lsp-0.1.0/pyproject.toml +42 -0
- vscode_common_python_lsp-0.1.0/setup.cfg +4 -0
- vscode_common_python_lsp-0.1.0/tests/test_code_actions.py +155 -0
- vscode_common_python_lsp-0.1.0/tests/test_context.py +102 -0
- vscode_common_python_lsp-0.1.0/tests/test_debug.py +119 -0
- vscode_common_python_lsp-0.1.0/tests/test_diagnostics.py +207 -0
- vscode_common_python_lsp-0.1.0/tests/test_formatting.py +73 -0
- vscode_common_python_lsp-0.1.0/tests/test_jsonrpc.py +470 -0
- vscode_common_python_lsp-0.1.0/tests/test_linting.py +78 -0
- vscode_common_python_lsp-0.1.0/tests/test_notebook.py +231 -0
- vscode_common_python_lsp-0.1.0/tests/test_package.py +9 -0
- vscode_common_python_lsp-0.1.0/tests/test_paths.py +268 -0
- vscode_common_python_lsp-0.1.0/tests/test_process_runner.py +258 -0
- vscode_common_python_lsp-0.1.0/tests/test_runner.py +213 -0
- vscode_common_python_lsp-0.1.0/tests/test_server.py +885 -0
- vscode_common_python_lsp-0.1.0/tests/test_version.py +94 -0
- vscode_common_python_lsp-0.1.0/vscode_common_python_lsp/__init__.py +137 -0
- vscode_common_python_lsp-0.1.0/vscode_common_python_lsp/code_actions.py +131 -0
- vscode_common_python_lsp-0.1.0/vscode_common_python_lsp/context.py +61 -0
- vscode_common_python_lsp-0.1.0/vscode_common_python_lsp/debug.py +49 -0
- vscode_common_python_lsp-0.1.0/vscode_common_python_lsp/diagnostics.py +275 -0
- vscode_common_python_lsp-0.1.0/vscode_common_python_lsp/formatting.py +48 -0
- vscode_common_python_lsp-0.1.0/vscode_common_python_lsp/jsonrpc.py +306 -0
- vscode_common_python_lsp-0.1.0/vscode_common_python_lsp/linting.py +62 -0
- vscode_common_python_lsp-0.1.0/vscode_common_python_lsp/notebook.py +245 -0
- vscode_common_python_lsp-0.1.0/vscode_common_python_lsp/paths.py +254 -0
- vscode_common_python_lsp-0.1.0/vscode_common_python_lsp/process_runner.py +94 -0
- vscode_common_python_lsp-0.1.0/vscode_common_python_lsp/runner.py +174 -0
- vscode_common_python_lsp-0.1.0/vscode_common_python_lsp/server.py +487 -0
- vscode_common_python_lsp-0.1.0/vscode_common_python_lsp/version.py +64 -0
- vscode_common_python_lsp-0.1.0/vscode_common_python_lsp.egg-info/PKG-INFO +22 -0
- vscode_common_python_lsp-0.1.0/vscode_common_python_lsp.egg-info/SOURCES.txt +35 -0
- vscode_common_python_lsp-0.1.0/vscode_common_python_lsp.egg-info/dependency_links.txt +1 -0
- vscode_common_python_lsp-0.1.0/vscode_common_python_lsp.egg-info/requires.txt +9 -0
- vscode_common_python_lsp-0.1.0/vscode_common_python_lsp.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vscode-common-python-lsp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Shared Python utilities for VS Code Python tool extensions
|
|
5
|
+
Author: Microsoft Corporation
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: pygls>=1.1.0
|
|
10
|
+
Requires-Dist: lsprotocol>=2023.0.0
|
|
11
|
+
Requires-Dist: packaging>=22.0
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
14
|
+
Requires-Dist: black; extra == "dev"
|
|
15
|
+
Requires-Dist: isort; extra == "dev"
|
|
16
|
+
Requires-Dist: flake8; extra == "dev"
|
|
17
|
+
|
|
18
|
+
# vscode-common-python-lsp (Python)
|
|
19
|
+
|
|
20
|
+
Shared Python utilities for VS Code Python tool extensions.
|
|
21
|
+
|
|
22
|
+
See the [main README](../README.md) for full documentation.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "vscode-common-python-lsp"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Shared Python utilities for VS Code Python tool extensions"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Microsoft Corporation" },
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
dependencies = [
|
|
17
|
+
"pygls>=1.1.0",
|
|
18
|
+
"lsprotocol>=2023.0.0",
|
|
19
|
+
"packaging>=22.0",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.optional-dependencies]
|
|
23
|
+
dev = [
|
|
24
|
+
"pytest>=7.0",
|
|
25
|
+
"black",
|
|
26
|
+
"isort",
|
|
27
|
+
"flake8",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[tool.setuptools.packages.find]
|
|
31
|
+
where = ["."]
|
|
32
|
+
include = ["vscode_common_python_lsp*"]
|
|
33
|
+
|
|
34
|
+
[tool.isort]
|
|
35
|
+
profile = "black"
|
|
36
|
+
|
|
37
|
+
[tool.black]
|
|
38
|
+
line-length = 88
|
|
39
|
+
target-version = ["py310"]
|
|
40
|
+
|
|
41
|
+
[tool.pytest.ini_options]
|
|
42
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation. All rights reserved.
|
|
2
|
+
# Licensed under the MIT License.
|
|
3
|
+
"""Tests for vscode_common_python_lsp.code_actions."""
|
|
4
|
+
|
|
5
|
+
import lsprotocol.types as lsp
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from vscode_common_python_lsp.code_actions import (
|
|
9
|
+
QuickFixRegistrationError,
|
|
10
|
+
QuickFixRegistry,
|
|
11
|
+
command_quick_fix,
|
|
12
|
+
create_workspace_edit,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
# QuickFixRegistry
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TestQuickFixRegistry:
|
|
21
|
+
def test_register_single_code(self):
|
|
22
|
+
registry = QuickFixRegistry()
|
|
23
|
+
|
|
24
|
+
@registry.quick_fix(codes="E001")
|
|
25
|
+
def handler(doc, diags):
|
|
26
|
+
return []
|
|
27
|
+
|
|
28
|
+
assert registry.solutions("E001") is handler
|
|
29
|
+
|
|
30
|
+
def test_register_multiple_codes(self):
|
|
31
|
+
registry = QuickFixRegistry()
|
|
32
|
+
|
|
33
|
+
@registry.quick_fix(codes=["E001", "E002"])
|
|
34
|
+
def handler(doc, diags):
|
|
35
|
+
return []
|
|
36
|
+
|
|
37
|
+
assert registry.solutions("E001") is handler
|
|
38
|
+
assert registry.solutions("E002") is handler
|
|
39
|
+
|
|
40
|
+
def test_unknown_code_returns_none(self):
|
|
41
|
+
registry = QuickFixRegistry()
|
|
42
|
+
assert registry.solutions("X999") is None
|
|
43
|
+
|
|
44
|
+
def test_none_code_returns_none(self):
|
|
45
|
+
registry = QuickFixRegistry()
|
|
46
|
+
assert registry.solutions(None) is None
|
|
47
|
+
|
|
48
|
+
def test_duplicate_code_raises(self):
|
|
49
|
+
registry = QuickFixRegistry()
|
|
50
|
+
|
|
51
|
+
@registry.quick_fix(codes="E001")
|
|
52
|
+
def handler1(doc, diags):
|
|
53
|
+
return []
|
|
54
|
+
|
|
55
|
+
with pytest.raises(QuickFixRegistrationError):
|
|
56
|
+
|
|
57
|
+
@registry.quick_fix(codes="E001")
|
|
58
|
+
def handler2(doc, diags):
|
|
59
|
+
return []
|
|
60
|
+
|
|
61
|
+
def test_duplicate_in_list_raises(self):
|
|
62
|
+
registry = QuickFixRegistry()
|
|
63
|
+
|
|
64
|
+
@registry.quick_fix(codes="E001")
|
|
65
|
+
def handler1(doc, diags):
|
|
66
|
+
return []
|
|
67
|
+
|
|
68
|
+
with pytest.raises(QuickFixRegistrationError):
|
|
69
|
+
|
|
70
|
+
@registry.quick_fix(codes=["E001", "E002"])
|
|
71
|
+
def handler2(doc, diags):
|
|
72
|
+
return []
|
|
73
|
+
|
|
74
|
+
def test_decorator_returns_function(self):
|
|
75
|
+
"""Decorator should return the original function for chaining."""
|
|
76
|
+
registry = QuickFixRegistry()
|
|
77
|
+
|
|
78
|
+
@registry.quick_fix(codes="E001")
|
|
79
|
+
def handler(doc, diags):
|
|
80
|
+
return ["result"]
|
|
81
|
+
|
|
82
|
+
# The decorated function is still callable
|
|
83
|
+
assert handler(None, []) == ["result"]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
# command_quick_fix
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class TestCommandQuickFix:
|
|
92
|
+
def test_basic(self):
|
|
93
|
+
diag = lsp.Diagnostic(
|
|
94
|
+
range=lsp.Range(
|
|
95
|
+
start=lsp.Position(line=0, character=0),
|
|
96
|
+
end=lsp.Position(line=0, character=0),
|
|
97
|
+
),
|
|
98
|
+
message="test",
|
|
99
|
+
)
|
|
100
|
+
action = command_quick_fix(
|
|
101
|
+
diagnostics=[diag],
|
|
102
|
+
title="Fix it",
|
|
103
|
+
command="editor.action.fix",
|
|
104
|
+
)
|
|
105
|
+
assert action.title == "Fix it"
|
|
106
|
+
assert action.kind == lsp.CodeActionKind.QuickFix
|
|
107
|
+
assert action.diagnostics == [diag]
|
|
108
|
+
assert action.command.command == "editor.action.fix"
|
|
109
|
+
|
|
110
|
+
def test_with_args(self):
|
|
111
|
+
action = command_quick_fix(
|
|
112
|
+
diagnostics=[],
|
|
113
|
+
title="Fix",
|
|
114
|
+
command="cmd",
|
|
115
|
+
args=["arg1", 42],
|
|
116
|
+
)
|
|
117
|
+
assert action.command.arguments == ["arg1", 42]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
# create_workspace_edit
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class TestCreateWorkspaceEdit:
|
|
126
|
+
def test_basic(self):
|
|
127
|
+
edit = lsp.TextEdit(
|
|
128
|
+
range=lsp.Range(
|
|
129
|
+
start=lsp.Position(line=0, character=0),
|
|
130
|
+
end=lsp.Position(line=1, character=0),
|
|
131
|
+
),
|
|
132
|
+
new_text="replaced\n",
|
|
133
|
+
)
|
|
134
|
+
ws_edit = create_workspace_edit("file:///a.py", 5, [edit])
|
|
135
|
+
assert len(ws_edit.document_changes) == 1
|
|
136
|
+
doc_edit = ws_edit.document_changes[0]
|
|
137
|
+
assert doc_edit.text_document.uri == "file:///a.py"
|
|
138
|
+
assert doc_edit.text_document.version == 5
|
|
139
|
+
assert doc_edit.edits == [edit]
|
|
140
|
+
|
|
141
|
+
def test_none_version_defaults_to_0(self):
|
|
142
|
+
ws_edit = create_workspace_edit("file:///a.py", None, [])
|
|
143
|
+
assert ws_edit.document_changes[0].text_document.version == 0
|
|
144
|
+
|
|
145
|
+
def test_version_zero_preserved(self):
|
|
146
|
+
"""Version 0 is a valid LSP document version (the initial version)."""
|
|
147
|
+
edit = lsp.TextEdit(
|
|
148
|
+
range=lsp.Range(
|
|
149
|
+
start=lsp.Position(line=0, character=0),
|
|
150
|
+
end=lsp.Position(line=0, character=0),
|
|
151
|
+
),
|
|
152
|
+
new_text="x",
|
|
153
|
+
)
|
|
154
|
+
ws_edit = create_workspace_edit("file:///a.py", 0, [edit])
|
|
155
|
+
assert ws_edit.document_changes[0].text_document.version == 0
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation. All rights reserved.
|
|
2
|
+
# Licensed under the MIT License.
|
|
3
|
+
"""Tests for context module."""
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from unittest.mock import patch
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from vscode_common_python_lsp.context import change_cwd, redirect_io, substitute_attr
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TestSubstituteAttr:
|
|
17
|
+
def test_restores_attribute(self):
|
|
18
|
+
class Obj:
|
|
19
|
+
value = "original"
|
|
20
|
+
|
|
21
|
+
obj = Obj()
|
|
22
|
+
with substitute_attr(obj, "value", "modified"):
|
|
23
|
+
assert obj.value == "modified"
|
|
24
|
+
assert obj.value == "original"
|
|
25
|
+
|
|
26
|
+
def test_with_sys_argv(self):
|
|
27
|
+
original_argv = sys.argv
|
|
28
|
+
with substitute_attr(sys, "argv", ["test", "--flag"]):
|
|
29
|
+
assert sys.argv == ["test", "--flag"]
|
|
30
|
+
assert sys.argv is original_argv
|
|
31
|
+
|
|
32
|
+
def test_restores_on_exception(self):
|
|
33
|
+
"""Attribute is restored even when the body raises."""
|
|
34
|
+
original_argv = sys.argv
|
|
35
|
+
with pytest.raises(RuntimeError):
|
|
36
|
+
with substitute_attr(sys, "argv", ["test"]):
|
|
37
|
+
raise RuntimeError("boom")
|
|
38
|
+
assert sys.argv is original_argv
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TestRedirectIo:
|
|
42
|
+
def test_redirect_stdout(self):
|
|
43
|
+
buf = io.StringIO()
|
|
44
|
+
with redirect_io("stdout", buf):
|
|
45
|
+
print("hello", end="")
|
|
46
|
+
assert buf.getvalue() == "hello"
|
|
47
|
+
|
|
48
|
+
def test_restores_original(self):
|
|
49
|
+
original = sys.stdout
|
|
50
|
+
buf = io.StringIO()
|
|
51
|
+
with redirect_io("stdout", buf):
|
|
52
|
+
pass
|
|
53
|
+
assert sys.stdout is original
|
|
54
|
+
|
|
55
|
+
def test_restores_on_exception(self):
|
|
56
|
+
"""Stream is restored even when the body raises."""
|
|
57
|
+
original = sys.stdout
|
|
58
|
+
buf = io.StringIO()
|
|
59
|
+
with pytest.raises(RuntimeError):
|
|
60
|
+
with redirect_io("stdout", buf):
|
|
61
|
+
raise RuntimeError("boom")
|
|
62
|
+
assert sys.stdout is original
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class TestChangeCwd:
|
|
66
|
+
def test_changes_and_restores(self, tmp_path):
|
|
67
|
+
original = os.getcwd()
|
|
68
|
+
with change_cwd(str(tmp_path)):
|
|
69
|
+
assert os.path.samefile(os.getcwd(), str(tmp_path))
|
|
70
|
+
assert os.getcwd() == original
|
|
71
|
+
|
|
72
|
+
def test_invalid_directory_does_not_raise(self):
|
|
73
|
+
original = os.getcwd()
|
|
74
|
+
with change_cwd("/nonexistent/path/that/does/not/exist"):
|
|
75
|
+
pass # should not raise, falls back gracefully
|
|
76
|
+
assert os.getcwd() == original
|
|
77
|
+
|
|
78
|
+
@pytest.mark.parametrize(
|
|
79
|
+
"error, path, message",
|
|
80
|
+
[
|
|
81
|
+
(PermissionError("Access denied"), "/restricted/path", "Access denied"),
|
|
82
|
+
(OSError("Some OS error"), "/inaccessible", "Some OS error"),
|
|
83
|
+
],
|
|
84
|
+
)
|
|
85
|
+
def test_chdir_error_logs_warning(self, caplog, error, path, message):
|
|
86
|
+
"""When os.chdir raises, body still runs and warning is logged."""
|
|
87
|
+
original = os.getcwd()
|
|
88
|
+
body_executed = False
|
|
89
|
+
|
|
90
|
+
with patch(
|
|
91
|
+
"vscode_common_python_lsp.context.os.chdir",
|
|
92
|
+
side_effect=error,
|
|
93
|
+
):
|
|
94
|
+
with caplog.at_level(logging.WARNING):
|
|
95
|
+
with change_cwd(path):
|
|
96
|
+
body_executed = True
|
|
97
|
+
assert os.path.normcase(os.getcwd()) == os.path.normcase(original)
|
|
98
|
+
|
|
99
|
+
assert body_executed
|
|
100
|
+
assert os.path.normcase(os.getcwd()) == os.path.normcase(original)
|
|
101
|
+
assert any(path in r.message for r in caplog.records)
|
|
102
|
+
assert any(message in r.message for r in caplog.records)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation. All rights reserved.
|
|
2
|
+
# Licensed under the MIT License.
|
|
3
|
+
"""Tests for vscode_common_python_lsp.debug."""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from unittest.mock import MagicMock, patch
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from vscode_common_python_lsp.debug import setup_debugpy
|
|
11
|
+
|
|
12
|
+
PATCH_UPDATE = "vscode_common_python_lsp.debug.update_sys_path"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_no_env_var_does_nothing():
|
|
16
|
+
"""When USE_DEBUGPY is not set, debugpy is not loaded."""
|
|
17
|
+
with patch.dict(os.environ, {}, clear=True):
|
|
18
|
+
os.environ.pop("USE_DEBUGPY", None)
|
|
19
|
+
os.environ.pop("DEBUGPY_PATH", None)
|
|
20
|
+
setup_debugpy()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@pytest.mark.parametrize("value", ["False", "false", "0", "no", ""])
|
|
24
|
+
def test_disabled_values_do_nothing(value):
|
|
25
|
+
"""Non-truthy USE_DEBUGPY values skip debugpy setup."""
|
|
26
|
+
with patch.dict(os.environ, {"USE_DEBUGPY": value}, clear=False):
|
|
27
|
+
setup_debugpy()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@pytest.mark.parametrize("value", ["True", "TRUE", "1", "T"])
|
|
31
|
+
def test_enabled_without_path_does_nothing(value):
|
|
32
|
+
"""USE_DEBUGPY enabled but no DEBUGPY_PATH skips setup."""
|
|
33
|
+
env = {"USE_DEBUGPY": value}
|
|
34
|
+
with patch.dict(os.environ, env, clear=False):
|
|
35
|
+
os.environ.pop("DEBUGPY_PATH", None)
|
|
36
|
+
setup_debugpy()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_enabled_with_path_connects():
|
|
40
|
+
"""USE_DEBUGPY + DEBUGPY_PATH triggers debugpy.connect."""
|
|
41
|
+
mock_debugpy = MagicMock()
|
|
42
|
+
debugger_dir = os.path.dirname(__file__)
|
|
43
|
+
env = {"USE_DEBUGPY": "True", "DEBUGPY_PATH": debugger_dir}
|
|
44
|
+
with patch.dict(os.environ, env, clear=False):
|
|
45
|
+
with patch.dict("sys.modules", {"debugpy": mock_debugpy}):
|
|
46
|
+
with patch(PATCH_UPDATE) as mock_update:
|
|
47
|
+
setup_debugpy(port=9999)
|
|
48
|
+
|
|
49
|
+
mock_update.assert_called_once_with(debugger_dir, "fromEnvironment")
|
|
50
|
+
mock_debugpy.connect.assert_called_once_with(9999)
|
|
51
|
+
mock_debugpy.breakpoint.assert_called_once()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_debugpy_path_ending_with_debugpy_strips_suffix():
|
|
55
|
+
"""DEBUGPY_PATH ending with 'debugpy' uses parent directory."""
|
|
56
|
+
test_parent = os.path.dirname(__file__)
|
|
57
|
+
debugpy_path = os.path.join(test_parent, "debugpy")
|
|
58
|
+
env = {"USE_DEBUGPY": "True", "DEBUGPY_PATH": debugpy_path}
|
|
59
|
+
|
|
60
|
+
mock_debugpy = MagicMock()
|
|
61
|
+
with patch.dict(os.environ, env, clear=False):
|
|
62
|
+
with patch.dict("sys.modules", {"debugpy": mock_debugpy}):
|
|
63
|
+
with patch(PATCH_UPDATE) as mock_update:
|
|
64
|
+
setup_debugpy()
|
|
65
|
+
mock_update.assert_called_once_with(test_parent, "fromEnvironment")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_debugpy_path_with_similar_name_not_stripped():
|
|
69
|
+
"""DEBUGPY_PATH like 'mydebugpy' should NOT be stripped."""
|
|
70
|
+
test_parent = os.path.dirname(__file__)
|
|
71
|
+
debugpy_path = os.path.join(test_parent, "mydebugpy")
|
|
72
|
+
env = {"USE_DEBUGPY": "True", "DEBUGPY_PATH": debugpy_path}
|
|
73
|
+
|
|
74
|
+
mock_debugpy = MagicMock()
|
|
75
|
+
with patch.dict(os.environ, env, clear=False):
|
|
76
|
+
with patch.dict("sys.modules", {"debugpy": mock_debugpy}):
|
|
77
|
+
with patch(PATCH_UPDATE) as mock_update:
|
|
78
|
+
setup_debugpy()
|
|
79
|
+
# Should NOT strip — "mydebugpy" basename != "debugpy"
|
|
80
|
+
mock_update.assert_called_once_with(debugpy_path, "fromEnvironment")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
# require_opt_in=False tests (for repos without USE_DEBUGPY guard)
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_no_opt_in_skips_use_debugpy_check():
|
|
89
|
+
"""With require_opt_in=False, USE_DEBUGPY is not checked."""
|
|
90
|
+
mock_debugpy = MagicMock()
|
|
91
|
+
debugger_dir = os.path.dirname(__file__)
|
|
92
|
+
env = {"DEBUGPY_PATH": debugger_dir}
|
|
93
|
+
with patch.dict(os.environ, env, clear=True):
|
|
94
|
+
with patch.dict("sys.modules", {"debugpy": mock_debugpy}):
|
|
95
|
+
with patch(PATCH_UPDATE):
|
|
96
|
+
setup_debugpy(require_opt_in=False)
|
|
97
|
+
|
|
98
|
+
mock_debugpy.connect.assert_called_once_with(5678)
|
|
99
|
+
mock_debugpy.breakpoint.assert_called_once()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_no_opt_in_still_requires_debugpy_path():
|
|
103
|
+
"""With require_opt_in=False but no DEBUGPY_PATH, nothing happens."""
|
|
104
|
+
with patch.dict(os.environ, {}, clear=True):
|
|
105
|
+
setup_debugpy(require_opt_in=False)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@pytest.mark.parametrize("value", ["False", "0", "no"])
|
|
109
|
+
def test_no_opt_in_ignores_use_debugpy_value(value):
|
|
110
|
+
"""With require_opt_in=False, USE_DEBUGPY value is irrelevant."""
|
|
111
|
+
mock_debugpy = MagicMock()
|
|
112
|
+
debugger_dir = os.path.dirname(__file__)
|
|
113
|
+
env = {"USE_DEBUGPY": value, "DEBUGPY_PATH": debugger_dir}
|
|
114
|
+
with patch.dict(os.environ, env, clear=False):
|
|
115
|
+
with patch.dict("sys.modules", {"debugpy": mock_debugpy}):
|
|
116
|
+
with patch(PATCH_UPDATE):
|
|
117
|
+
setup_debugpy(require_opt_in=False)
|
|
118
|
+
|
|
119
|
+
mock_debugpy.connect.assert_called_once()
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation. All rights reserved.
|
|
2
|
+
# Licensed under the MIT License.
|
|
3
|
+
"""Tests for vscode_common_python_lsp.diagnostics."""
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
import lsprotocol.types as lsp
|
|
8
|
+
|
|
9
|
+
from vscode_common_python_lsp.diagnostics import (
|
|
10
|
+
ParsedRecord,
|
|
11
|
+
get_severity,
|
|
12
|
+
make_diagnostic,
|
|
13
|
+
parse_diagnostics_regex,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
# get_severity
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
FLAKE8_SEVERITY = {"E": "Error", "F": "Error", "I": "Information", "W": "Warning"}
|
|
21
|
+
PYLINT_SEVERITY = {
|
|
22
|
+
"convention": "Information",
|
|
23
|
+
"error": "Error",
|
|
24
|
+
"fatal": "Error",
|
|
25
|
+
"refactor": "Hint",
|
|
26
|
+
"warning": "Warning",
|
|
27
|
+
}
|
|
28
|
+
MYPY_SEVERITY = {"error": "Error", "note": "Information"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TestGetSeverity:
|
|
32
|
+
def test_lookup_by_code(self):
|
|
33
|
+
assert (
|
|
34
|
+
get_severity("E501", "Error", FLAKE8_SEVERITY)
|
|
35
|
+
== lsp.DiagnosticSeverity.Error
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def test_lookup_by_code_type(self):
|
|
39
|
+
assert (
|
|
40
|
+
get_severity("X999", "W", FLAKE8_SEVERITY) == lsp.DiagnosticSeverity.Warning
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def test_fallback_to_default(self):
|
|
44
|
+
assert get_severity("X999", "unknown", {}) == lsp.DiagnosticSeverity.Error
|
|
45
|
+
|
|
46
|
+
def test_custom_default(self):
|
|
47
|
+
assert (
|
|
48
|
+
get_severity("X999", "unknown", {}, default="Warning")
|
|
49
|
+
== lsp.DiagnosticSeverity.Warning
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def test_pylint_symbol_lookup(self):
|
|
53
|
+
severity_map = {**PYLINT_SEVERITY, "line-too-long": "Warning"}
|
|
54
|
+
result = get_severity(
|
|
55
|
+
"C0301", "convention", severity_map, symbol="line-too-long"
|
|
56
|
+
)
|
|
57
|
+
assert result == lsp.DiagnosticSeverity.Warning
|
|
58
|
+
|
|
59
|
+
def test_pylint_symbol_takes_priority(self):
|
|
60
|
+
severity_map = {
|
|
61
|
+
"line-too-long": "Hint",
|
|
62
|
+
"C0301": "Error",
|
|
63
|
+
"convention": "Information",
|
|
64
|
+
}
|
|
65
|
+
result = get_severity(
|
|
66
|
+
"C0301", "convention", severity_map, symbol="line-too-long"
|
|
67
|
+
)
|
|
68
|
+
assert result == lsp.DiagnosticSeverity.Hint
|
|
69
|
+
|
|
70
|
+
def test_mypy_code_lookup(self):
|
|
71
|
+
assert (
|
|
72
|
+
get_severity("error", "error", MYPY_SEVERITY)
|
|
73
|
+
== lsp.DiagnosticSeverity.Error
|
|
74
|
+
)
|
|
75
|
+
assert (
|
|
76
|
+
get_severity("note", "note", MYPY_SEVERITY)
|
|
77
|
+
== lsp.DiagnosticSeverity.Information
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def test_invalid_severity_name(self):
|
|
81
|
+
result = get_severity("X", "X", {"X": "NotAReal"})
|
|
82
|
+
assert result == lsp.DiagnosticSeverity.Error
|
|
83
|
+
|
|
84
|
+
def test_code_type_fallback(self):
|
|
85
|
+
"""Falls back to default when neither code nor code_type matches."""
|
|
86
|
+
assert get_severity("E501", "", {"E": "Error"}) == lsp.DiagnosticSeverity.Error
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
# make_diagnostic
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class TestMakeDiagnostic:
|
|
95
|
+
def test_basic(self):
|
|
96
|
+
diag = make_diagnostic(
|
|
97
|
+
line=10,
|
|
98
|
+
column=5,
|
|
99
|
+
message="test error",
|
|
100
|
+
severity=lsp.DiagnosticSeverity.Error,
|
|
101
|
+
code="E001",
|
|
102
|
+
source="TestTool",
|
|
103
|
+
)
|
|
104
|
+
assert diag.range.start.line == 10
|
|
105
|
+
assert diag.range.start.character == 5
|
|
106
|
+
assert diag.range.end.line == 10
|
|
107
|
+
assert diag.range.end.character == 5
|
|
108
|
+
assert diag.message == "test error"
|
|
109
|
+
assert diag.severity == lsp.DiagnosticSeverity.Error
|
|
110
|
+
assert diag.code == "E001"
|
|
111
|
+
assert diag.source == "TestTool"
|
|
112
|
+
|
|
113
|
+
def test_with_end_range(self):
|
|
114
|
+
diag = make_diagnostic(
|
|
115
|
+
line=10,
|
|
116
|
+
column=0,
|
|
117
|
+
message="msg",
|
|
118
|
+
severity=lsp.DiagnosticSeverity.Warning,
|
|
119
|
+
end_line=12,
|
|
120
|
+
end_column=5,
|
|
121
|
+
)
|
|
122
|
+
assert diag.range.end.line == 12
|
|
123
|
+
assert diag.range.end.character == 5
|
|
124
|
+
|
|
125
|
+
def test_empty_code_is_none(self):
|
|
126
|
+
diag = make_diagnostic(
|
|
127
|
+
line=0, column=0, message="m", severity=lsp.DiagnosticSeverity.Error
|
|
128
|
+
)
|
|
129
|
+
assert diag.code is None
|
|
130
|
+
assert diag.source is None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ---------------------------------------------------------------------------
|
|
134
|
+
# parse_diagnostics_regex
|
|
135
|
+
# ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
# Flake8-style regex
|
|
138
|
+
FLAKE8_RE = re.compile(
|
|
139
|
+
r"(?P<line>\d+),(?P<column>-?\d+),"
|
|
140
|
+
r"(?P<type>\w+),(?P<code>\w+\d+):(?P<message>[^\r\n]*)"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class TestParseDiagnosticsRegex:
|
|
145
|
+
def test_flake8_output(self):
|
|
146
|
+
content = (
|
|
147
|
+
"5,1,Error,E302:expected 2 blank lines, got 1\n"
|
|
148
|
+
"10,80,Warning,W501:line too long"
|
|
149
|
+
)
|
|
150
|
+
result = parse_diagnostics_regex(content, FLAKE8_RE, FLAKE8_SEVERITY, "Flake8")
|
|
151
|
+
assert len(result) == 2
|
|
152
|
+
assert result[0].range.start.line == 4 # 5 - 1 (line_at_1)
|
|
153
|
+
assert result[0].range.start.character == 0 # 1 - 1 (col_at_1)
|
|
154
|
+
assert result[0].code == "E302"
|
|
155
|
+
assert result[0].message == "expected 2 blank lines, got 1"
|
|
156
|
+
assert result[0].source == "Flake8"
|
|
157
|
+
|
|
158
|
+
def test_no_matches(self):
|
|
159
|
+
result = parse_diagnostics_regex(
|
|
160
|
+
"no matching lines\n", FLAKE8_RE, FLAKE8_SEVERITY, "Flake8"
|
|
161
|
+
)
|
|
162
|
+
assert result == []
|
|
163
|
+
|
|
164
|
+
def test_empty_content(self):
|
|
165
|
+
result = parse_diagnostics_regex("", FLAKE8_RE, FLAKE8_SEVERITY, "Flake8")
|
|
166
|
+
assert result == []
|
|
167
|
+
|
|
168
|
+
def test_line_at_0(self):
|
|
169
|
+
content = "0,0,Error,E001:msg\n"
|
|
170
|
+
result = parse_diagnostics_regex(
|
|
171
|
+
content,
|
|
172
|
+
FLAKE8_RE,
|
|
173
|
+
FLAKE8_SEVERITY,
|
|
174
|
+
"Flake8",
|
|
175
|
+
line_at_1=False,
|
|
176
|
+
col_at_1=False,
|
|
177
|
+
)
|
|
178
|
+
assert result[0].range.start.line == 0
|
|
179
|
+
assert result[0].range.start.character == 0
|
|
180
|
+
|
|
181
|
+
def test_record_callback(self):
|
|
182
|
+
"""record_callback lets tools like mypy do custom diagnostic construction."""
|
|
183
|
+
records: list[ParsedRecord] = []
|
|
184
|
+
|
|
185
|
+
def capture(record: ParsedRecord) -> lsp.Diagnostic | None:
|
|
186
|
+
records.append(record)
|
|
187
|
+
return make_diagnostic(
|
|
188
|
+
line=record.line,
|
|
189
|
+
column=record.column,
|
|
190
|
+
message=f"CUSTOM: {record.message}",
|
|
191
|
+
severity=lsp.DiagnosticSeverity.Hint,
|
|
192
|
+
code=record.code,
|
|
193
|
+
source="Custom",
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
content = "5,1,Error,E302:expected 2 blank lines, got 1\n"
|
|
197
|
+
result = parse_diagnostics_regex(
|
|
198
|
+
content, FLAKE8_RE, {}, "Flake8", record_callback=capture
|
|
199
|
+
)
|
|
200
|
+
assert len(records) == 1
|
|
201
|
+
assert records[0].code == "E302"
|
|
202
|
+
assert result[0].message == "CUSTOM: expected 2 blank lines, got 1"
|
|
203
|
+
|
|
204
|
+
def test_negative_column_clamped(self):
|
|
205
|
+
content = "1,-1,Error,E001:msg\n"
|
|
206
|
+
result = parse_diagnostics_regex(content, FLAKE8_RE, FLAKE8_SEVERITY, "Flake8")
|
|
207
|
+
assert result[0].range.start.character == 0 # clamped to 0
|