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.
Files changed (37) hide show
  1. vscode_common_python_lsp-0.1.0/PKG-INFO +22 -0
  2. vscode_common_python_lsp-0.1.0/README.md +5 -0
  3. vscode_common_python_lsp-0.1.0/pyproject.toml +42 -0
  4. vscode_common_python_lsp-0.1.0/setup.cfg +4 -0
  5. vscode_common_python_lsp-0.1.0/tests/test_code_actions.py +155 -0
  6. vscode_common_python_lsp-0.1.0/tests/test_context.py +102 -0
  7. vscode_common_python_lsp-0.1.0/tests/test_debug.py +119 -0
  8. vscode_common_python_lsp-0.1.0/tests/test_diagnostics.py +207 -0
  9. vscode_common_python_lsp-0.1.0/tests/test_formatting.py +73 -0
  10. vscode_common_python_lsp-0.1.0/tests/test_jsonrpc.py +470 -0
  11. vscode_common_python_lsp-0.1.0/tests/test_linting.py +78 -0
  12. vscode_common_python_lsp-0.1.0/tests/test_notebook.py +231 -0
  13. vscode_common_python_lsp-0.1.0/tests/test_package.py +9 -0
  14. vscode_common_python_lsp-0.1.0/tests/test_paths.py +268 -0
  15. vscode_common_python_lsp-0.1.0/tests/test_process_runner.py +258 -0
  16. vscode_common_python_lsp-0.1.0/tests/test_runner.py +213 -0
  17. vscode_common_python_lsp-0.1.0/tests/test_server.py +885 -0
  18. vscode_common_python_lsp-0.1.0/tests/test_version.py +94 -0
  19. vscode_common_python_lsp-0.1.0/vscode_common_python_lsp/__init__.py +137 -0
  20. vscode_common_python_lsp-0.1.0/vscode_common_python_lsp/code_actions.py +131 -0
  21. vscode_common_python_lsp-0.1.0/vscode_common_python_lsp/context.py +61 -0
  22. vscode_common_python_lsp-0.1.0/vscode_common_python_lsp/debug.py +49 -0
  23. vscode_common_python_lsp-0.1.0/vscode_common_python_lsp/diagnostics.py +275 -0
  24. vscode_common_python_lsp-0.1.0/vscode_common_python_lsp/formatting.py +48 -0
  25. vscode_common_python_lsp-0.1.0/vscode_common_python_lsp/jsonrpc.py +306 -0
  26. vscode_common_python_lsp-0.1.0/vscode_common_python_lsp/linting.py +62 -0
  27. vscode_common_python_lsp-0.1.0/vscode_common_python_lsp/notebook.py +245 -0
  28. vscode_common_python_lsp-0.1.0/vscode_common_python_lsp/paths.py +254 -0
  29. vscode_common_python_lsp-0.1.0/vscode_common_python_lsp/process_runner.py +94 -0
  30. vscode_common_python_lsp-0.1.0/vscode_common_python_lsp/runner.py +174 -0
  31. vscode_common_python_lsp-0.1.0/vscode_common_python_lsp/server.py +487 -0
  32. vscode_common_python_lsp-0.1.0/vscode_common_python_lsp/version.py +64 -0
  33. vscode_common_python_lsp-0.1.0/vscode_common_python_lsp.egg-info/PKG-INFO +22 -0
  34. vscode_common_python_lsp-0.1.0/vscode_common_python_lsp.egg-info/SOURCES.txt +35 -0
  35. vscode_common_python_lsp-0.1.0/vscode_common_python_lsp.egg-info/dependency_links.txt +1 -0
  36. vscode_common_python_lsp-0.1.0/vscode_common_python_lsp.egg-info/requires.txt +9 -0
  37. 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,5 @@
1
+ # vscode-common-python-lsp (Python)
2
+
3
+ Shared Python utilities for VS Code Python tool extensions.
4
+
5
+ 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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