diff --git a/src/test/python_tests/test_dmypy_status_file.py b/src/test/python_tests/test_dmypy_status_file.py new file mode 100644 index 0000000..af58e96 --- /dev/null +++ b/src/test/python_tests/test_dmypy_status_file.py @@ -0,0 +1,195 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +"""Unit tests for _get_dmypy_args() custom daemonStatusFile support.""" + +import os +import pathlib +import sys +import types + + +# --------------------------------------------------------------------------- +# Stub out bundled LSP dependencies so lsp_server can be imported. +# --------------------------------------------------------------------------- +def _setup_mocks(): + class _MockLS: + def __init__(self, **kwargs): + pass + + def feature(self, *args, **kwargs): + return lambda f: f + + def command(self, *args, **kwargs): + return lambda f: f + + def show_message_log(self, *args, **kwargs): + pass + + def show_message(self, *args, **kwargs): + pass + + def window_log_message(self, *args, **kwargs): + pass + + mock_server = types.ModuleType("pygls.lsp.server") + mock_server.LanguageServer = _MockLS + + mock_workspace = types.ModuleType("pygls.workspace") + mock_workspace.TextDocument = type("TextDocument", (), {"path": None}) + + mock_pygls = types.ModuleType("pygls") + mock_pygls_uris = types.ModuleType("pygls.uris") + mock_pygls_uris.from_fs_path = lambda p: "file://" + p + + mock_lsp = types.ModuleType("lsprotocol.types") + for _name in [ + "TEXT_DOCUMENT_DID_OPEN", + "TEXT_DOCUMENT_DID_SAVE", + "TEXT_DOCUMENT_DID_CLOSE", + "TEXT_DOCUMENT_FORMATTING", + "INITIALIZE", + "EXIT", + "SHUTDOWN", + ]: + setattr(mock_lsp, _name, _name) + for _name in [ + "Diagnostic", + "DiagnosticSeverity", + "DidCloseTextDocumentParams", + "DidOpenTextDocumentParams", + "DidSaveTextDocumentParams", + "DocumentFormattingParams", + "InitializeParams", + "LogMessageParams", + "Position", + "Range", + "TextEdit", + ]: + setattr(mock_lsp, _name, type(_name, (), {"__init__": lambda self, **kw: None})) + mock_lsp.MessageType = type( + "MessageType", (), {"Log": 4, "Error": 1, "Warning": 2, "Info": 3, "Debug": 5} + ) + + mock_lsp_utils = types.ModuleType("lsp_utils") + mock_lsp_utils.normalize_path = lambda p: str(pathlib.Path(p).resolve()) + + for _mod_name, _mod in [ + ("pygls", mock_pygls), + ("pygls.lsp", types.ModuleType("pygls.lsp")), + ("pygls.lsp.server", mock_server), + ("pygls.workspace", mock_workspace), + ("pygls.uris", mock_pygls_uris), + ("lsprotocol", types.ModuleType("lsprotocol")), + ("lsprotocol.types", mock_lsp), + ("lsp_utils", mock_lsp_utils), + ("packaging", types.ModuleType("packaging")), + ("packaging.version", types.ModuleType("packaging.version")), + ]: + if _mod_name not in sys.modules: + sys.modules[_mod_name] = _mod + + # Ensure normalize_path is available even if lsp_utils was mocked by another test + if not hasattr(sys.modules["lsp_utils"], "normalize_path"): + sys.modules["lsp_utils"].normalize_path = lambda p: str( + pathlib.Path(p).resolve() + ) + + import packaging.version as _pv + + _pv.Version = lambda v: v + _pv.parse = lambda v: v + + tool_dir = str(pathlib.Path(__file__).parents[3] / "bundled" / "tool") + if tool_dir not in sys.path: + sys.path.insert(0, tool_dir) + + +_setup_mocks() + +import lsp_server # noqa: E402 + + +def _make_settings(workspace_path, daemon_status_file=""): + return { + "workspaceFS": workspace_path, + "daemonStatusFile": daemon_status_file, + } + + +def _clear_dmypy_cache(): + lsp_server.DMYPY_ARGS.clear() + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +def test_dmypy_args_uses_custom_status_file(): + """When daemonStatusFile is set, _get_dmypy_args uses it instead of generating one.""" + _clear_dmypy_cache() + try: + settings = _make_settings("/workspace/project", "/custom/status.json") + result = lsp_server._get_dmypy_args(settings, "run") + assert "--status-file" in result + idx = result.index("--status-file") + assert result[idx + 1] == "/custom/status.json" + finally: + _clear_dmypy_cache() + + +def test_dmypy_args_generates_status_file_when_not_set(): + """When daemonStatusFile is empty, _get_dmypy_args generates a unique status file.""" + _clear_dmypy_cache() + # Ensure DMYPY_STATUS_FILE_ROOT is set for auto-generation + old_root = lsp_server.DMYPY_STATUS_FILE_ROOT + lsp_server.DMYPY_STATUS_FILE_ROOT = pathlib.Path(os.environ.get("TEMP", "/tmp")) + try: + settings = _make_settings("/workspace/auto", "") + result = lsp_server._get_dmypy_args(settings, "run") + assert "--status-file" in result + idx = result.index("--status-file") + # Should be a generated path, not empty + assert result[idx + 1] != "" + assert "status-" in result[idx + 1] + finally: + lsp_server.DMYPY_STATUS_FILE_ROOT = old_root + _clear_dmypy_cache() + + +def test_dmypy_args_run_includes_separator(): + """The 'run' command includes a '--' separator after the command.""" + _clear_dmypy_cache() + try: + settings = _make_settings("/workspace/sep_test", "/my/status.json") + result = lsp_server._get_dmypy_args(settings, "run") + assert result[-2:] == ["run", "--"] + finally: + _clear_dmypy_cache() + + +def test_dmypy_args_stop_no_separator(): + """Control commands (stop, kill, etc.) do not include the '--' separator.""" + _clear_dmypy_cache() + try: + settings = _make_settings("/workspace/stop_test", "/my/status.json") + result = lsp_server._get_dmypy_args(settings, "stop") + assert result[-1] == "stop" + assert "--" not in result[result.index("stop") :] + finally: + _clear_dmypy_cache() + + +def test_dmypy_args_caches_per_workspace(): + """DMYPY_ARGS are cached per workspace; the status file is set once.""" + _clear_dmypy_cache() + try: + settings = _make_settings("/workspace/cached", "/cached/status.json") + result1 = lsp_server._get_dmypy_args(settings, "run") + result2 = lsp_server._get_dmypy_args(settings, "check") + # Both should use the same status file + idx1 = result1.index("--status-file") + idx2 = result2.index("--status-file") + assert result1[idx1 + 1] == result2[idx2 + 1] == "/cached/status.json" + finally: + _clear_dmypy_cache() diff --git a/src/test/python_tests/test_get_cwd.py b/src/test/python_tests/test_get_cwd.py index 437e239..aa86e06 100644 --- a/src/test/python_tests/test_get_cwd.py +++ b/src/test/python_tests/test_get_cwd.py @@ -278,3 +278,69 @@ def test_nearest_config_falls_back_when_no_config_found(): doc = _make_doc(doc_path) result = lsp_server.get_cwd(settings, doc) assert result == workspace + + +def test_nearest_config_finds_dot_mypy_ini(): + """${nearestConfig} finds .mypy.ini in parent directory.""" + with tempfile.TemporaryDirectory() as workspace: + src_dir = os.path.join(workspace, "src") + os.makedirs(src_dir) + config_file = os.path.join(workspace, ".mypy.ini") + pathlib.Path(config_file).touch() + doc_path = os.path.join(src_dir, "foo.py") + pathlib.Path(doc_path).touch() + + settings = {"workspaceFS": workspace, "cwd": "${nearestConfig}"} + doc = _make_doc(doc_path) + result = lsp_server.get_cwd(settings, doc) + assert result == workspace + + +def test_nearest_config_finds_pyproject_toml(): + """${nearestConfig} finds pyproject.toml in parent directory.""" + with tempfile.TemporaryDirectory() as workspace: + src_dir = os.path.join(workspace, "src") + os.makedirs(src_dir) + config_file = os.path.join(workspace, "pyproject.toml") + pathlib.Path(config_file).touch() + doc_path = os.path.join(src_dir, "foo.py") + pathlib.Path(doc_path).touch() + + settings = {"workspaceFS": workspace, "cwd": "${nearestConfig}"} + doc = _make_doc(doc_path) + result = lsp_server.get_cwd(settings, doc) + assert result == workspace + + +def test_nearest_config_finds_setup_cfg(): + """${nearestConfig} finds setup.cfg in parent directory.""" + with tempfile.TemporaryDirectory() as workspace: + src_dir = os.path.join(workspace, "src") + os.makedirs(src_dir) + config_file = os.path.join(workspace, "setup.cfg") + pathlib.Path(config_file).touch() + doc_path = os.path.join(src_dir, "foo.py") + pathlib.Path(doc_path).touch() + + settings = {"workspaceFS": workspace, "cwd": "${nearestConfig}"} + doc = _make_doc(doc_path) + result = lsp_server.get_cwd(settings, doc) + assert result == workspace + + +def test_nearest_config_prefers_closest_config(): + """${nearestConfig} picks the nearest directory containing any config file.""" + with tempfile.TemporaryDirectory() as workspace: + inner = os.path.join(workspace, "pkg") + src_dir = os.path.join(inner, "sub") + os.makedirs(src_dir) + # Place setup.cfg in workspace root and .mypy.ini closer to the doc + pathlib.Path(os.path.join(workspace, "setup.cfg")).touch() + pathlib.Path(os.path.join(inner, ".mypy.ini")).touch() + doc_path = os.path.join(src_dir, "foo.py") + pathlib.Path(doc_path).touch() + + settings = {"workspaceFS": workspace, "cwd": "${nearestConfig}"} + doc = _make_doc(doc_path) + result = lsp_server.get_cwd(settings, doc) + assert result == inner diff --git a/src/test/python_tests/test_global_defaults.py b/src/test/python_tests/test_global_defaults.py new file mode 100644 index 0000000..b91b16f --- /dev/null +++ b/src/test/python_tests/test_global_defaults.py @@ -0,0 +1,166 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +"""Unit tests for _get_global_defaults() in lsp_server. + +Verifies that global-level settings (e.g. ignorePatterns) propagate into the +defaults returned by _get_global_defaults(), which serves as the fallback for +per-workspace settings. +""" + +import pathlib +import sys +import types + + +# --------------------------------------------------------------------------- +# Stub out bundled LSP dependencies so lsp_server can be imported without the +# full VS Code extension environment. +# --------------------------------------------------------------------------- +def _setup_mocks(): + class _MockLS: + def __init__(self, **kwargs): + pass + + def feature(self, *args, **kwargs): + return lambda f: f + + def command(self, *args, **kwargs): + return lambda f: f + + def show_message_log(self, *args, **kwargs): + pass + + def show_message(self, *args, **kwargs): + pass + + def window_log_message(self, *args, **kwargs): + pass + + mock_server = types.ModuleType("pygls.lsp.server") + mock_server.LanguageServer = _MockLS + + mock_workspace = types.ModuleType("pygls.workspace") + mock_workspace.TextDocument = type("TextDocument", (), {"path": None}) + + mock_pygls = types.ModuleType("pygls") + mock_pygls_uris = types.ModuleType("pygls.uris") + mock_pygls_uris.from_fs_path = lambda p: "file://" + p + + mock_lsp = types.ModuleType("lsprotocol.types") + for _name in [ + "TEXT_DOCUMENT_DID_OPEN", + "TEXT_DOCUMENT_DID_SAVE", + "TEXT_DOCUMENT_DID_CLOSE", + "TEXT_DOCUMENT_FORMATTING", + "INITIALIZE", + "EXIT", + "SHUTDOWN", + ]: + setattr(mock_lsp, _name, _name) + for _name in [ + "Diagnostic", + "DiagnosticSeverity", + "DidCloseTextDocumentParams", + "DidOpenTextDocumentParams", + "DidSaveTextDocumentParams", + "DocumentFormattingParams", + "InitializeParams", + "LogMessageParams", + "Position", + "Range", + "TextEdit", + ]: + setattr(mock_lsp, _name, type(_name, (), {"__init__": lambda self, **kw: None})) + mock_lsp.MessageType = type( + "MessageType", (), {"Log": 4, "Error": 1, "Warning": 2, "Info": 3, "Debug": 5} + ) + + mock_lsp_utils = types.ModuleType("lsp_utils") + mock_lsp_utils.normalize_path = lambda p: str(pathlib.Path(p).resolve()) + + for _mod_name, _mod in [ + ("pygls", mock_pygls), + ("pygls.lsp", types.ModuleType("pygls.lsp")), + ("pygls.lsp.server", mock_server), + ("pygls.workspace", mock_workspace), + ("pygls.uris", mock_pygls_uris), + ("lsprotocol", types.ModuleType("lsprotocol")), + ("lsprotocol.types", mock_lsp), + ("lsp_utils", mock_lsp_utils), + ("packaging", types.ModuleType("packaging")), + ("packaging.version", types.ModuleType("packaging.version")), + ]: + if _mod_name not in sys.modules: + sys.modules[_mod_name] = _mod + + # Ensure normalize_path is available even if lsp_utils was mocked by another test + if not hasattr(sys.modules["lsp_utils"], "normalize_path"): + sys.modules["lsp_utils"].normalize_path = lambda p: str( + pathlib.Path(p).resolve() + ) + + import packaging.version as _pv + + _pv.Version = lambda v: v + _pv.parse = lambda v: v + + tool_dir = str(pathlib.Path(__file__).parents[3] / "bundled" / "tool") + if tool_dir not in sys.path: + sys.path.insert(0, tool_dir) + + +_setup_mocks() + +import lsp_server # noqa: E402 + + +def _with_global_settings(overrides): + """Context-manager-like helper that restores GLOBAL_SETTINGS on exit.""" + + class _Ctx: + def __enter__(self): + self._old = lsp_server.GLOBAL_SETTINGS.copy() + lsp_server.GLOBAL_SETTINGS.update(overrides) + return self + + def __exit__(self, *args): + lsp_server.GLOBAL_SETTINGS.clear() + lsp_server.GLOBAL_SETTINGS.update(self._old) + + return _Ctx() + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +def test_global_defaults_ignorePatterns_from_global_settings(): + """ignorePatterns set in GLOBAL_SETTINGS are returned by _get_global_defaults.""" + with _with_global_settings({"ignorePatterns": ["*.pyi", "test_*"]}): + defaults = lsp_server._get_global_defaults() + assert defaults["ignorePatterns"] == ["*.pyi", "test_*"] + + +def test_global_defaults_ignorePatterns_empty_when_not_set(): + """ignorePatterns defaults to [] when not present in GLOBAL_SETTINGS.""" + with _with_global_settings({}): + # Ensure ignorePatterns is not in GLOBAL_SETTINGS + lsp_server.GLOBAL_SETTINGS.pop("ignorePatterns", None) + defaults = lsp_server._get_global_defaults() + assert defaults["ignorePatterns"] == [] + + +def test_global_defaults_daemonStatusFile_from_global_settings(): + """daemonStatusFile set in GLOBAL_SETTINGS is returned by _get_global_defaults.""" + with _with_global_settings({"daemonStatusFile": "/custom/status.json"}): + defaults = lsp_server._get_global_defaults() + assert defaults["daemonStatusFile"] == "/custom/status.json" + + +def test_global_defaults_daemonStatusFile_empty_when_not_set(): + """daemonStatusFile defaults to '' when not present in GLOBAL_SETTINGS.""" + with _with_global_settings({}): + lsp_server.GLOBAL_SETTINGS.pop("daemonStatusFile", None) + defaults = lsp_server._get_global_defaults() + assert defaults["daemonStatusFile"] == "" diff --git a/src/test/ts_tests/tests/common/python.unit.test.ts b/src/test/ts_tests/tests/common/python.unit.test.ts index 77013b0..602edbe 100644 --- a/src/test/ts_tests/tests/common/python.unit.test.ts +++ b/src/test/ts_tests/tests/common/python.unit.test.ts @@ -3,6 +3,7 @@ import { assert } from 'chai'; import * as sinon from 'sinon'; +import { PythonEnvironments } from '@vscode/python-environments'; import { EventEmitter, extensions, Uri } from 'vscode'; import { PythonExtension } from '@vscode/python-extension'; import { getInterpreterDetails, resetCachedApis } from '../../../../common/python'; @@ -158,4 +159,74 @@ suite('Python Interpreter Resolution Tests', () => { assert.isUndefined(result.path); assert.isTrue(mockLegacyApi.environments.resolveEnvironment.calledOnce); }); + + test('Finds interpreter using PythonEnvironments.api() from @vscode/python-environments', async () => { + const mockEnvsApi = { + getEnvironment: sinon.stub().resolves({ + version: '3.12.0', + execInfo: { + run: { executable: '/usr/bin/python3.12', args: [] }, + }, + envId: { id: 'test-env', managerId: 'test-manager' }, + sysPrefix: '/usr', + name: 'test', + displayName: 'Python 3.12', + environmentPath: Uri.file('/usr/bin/python3.12'), + }), + resolveEnvironment: sinon.stub(), + onDidChangeEnvironment: new EventEmitter().event, + }; + + const envsApiStub = sinon.stub(PythonEnvironments, 'api').resolves(mockEnvsApi as any); + + const result = await getInterpreterDetails(Uri.file('/test/workspace')); + + assert.isDefined(result.path); + assert.strictEqual(result.path![0], '/usr/bin/python3.12'); + assert.isTrue(mockEnvsApi.getEnvironment.calledOnce); + // Legacy API should not be called when envs API succeeds + assert.isTrue(pythonExtensionApiStub.notCalled); + + envsApiStub.restore(); + }); + + test('Falls back to legacy API when PythonEnvironments.api() throws', async () => { + const envsApiStub = sinon.stub(PythonEnvironments, 'api').rejects(new Error('Not available')); + + const interpreterUri = Uri.file('/usr/bin/python3.10'); + const mockLegacyApi = { + environments: { + getActiveEnvironmentPath: sinon.stub().returns('/usr/bin/python3.10'), + resolveEnvironment: sinon.stub().resolves({ + executable: { + uri: interpreterUri, + bitness: '64-bit', + sysPrefix: '/usr', + }, + version: { + major: 3, + minor: 10, + micro: 0, + release: { level: 'final', serial: 0 }, + sysVersion: '3.10.0', + }, + }), + onDidChangeActiveEnvironmentPath: new EventEmitter().event, + }, + debug: { + getDebuggerPackagePath: sinon.stub(), + }, + }; + + pythonExtensionApiStub.resolves(mockLegacyApi); + + const result = await getInterpreterDetails(Uri.file('/test/workspace')); + + assert.isDefined(result.path); + assert.strictEqual(result.path![0], interpreterUri.fsPath); + assert.isTrue(envsApiStub.calledOnce); + assert.isTrue(mockLegacyApi.environments.resolveEnvironment.calledOnce); + + envsApiStub.restore(); + }); }); diff --git a/src/test/ts_tests/tests/common/settings.unit.test.ts b/src/test/ts_tests/tests/common/settings.unit.test.ts index 1961168..44a0f96 100644 --- a/src/test/ts_tests/tests/common/settings.unit.test.ts +++ b/src/test/ts_tests/tests/common/settings.unit.test.ts @@ -8,7 +8,7 @@ import * as TypeMoq from 'typemoq'; import { Uri, WorkspaceConfiguration, WorkspaceFolder } from 'vscode'; import { EXTENSION_ROOT_DIR } from '../../../../common/constants'; import * as python from '../../../../common/python'; -import { ISettings, getWorkspaceSettings } from '../../../../common/settings'; +import { ISettings, checkIfConfigurationChanged, getWorkspaceSettings } from '../../../../common/settings'; import * as vscodeapi from '../../../../common/vscodeapi'; // eslint-disable-next-line @typescript-eslint/naming-convention @@ -24,7 +24,7 @@ suite('Settings Tests', () => { let getWorkspaceFoldersStub: sinon.SinonStub; let configMock: TypeMoq.IMock; let pythonConfigMock: TypeMoq.IMock; - let workspace1: WorkspaceFolder = { + const workspace1: WorkspaceFolder = { uri: Uri.file(path.join(EXTENSION_ROOT_DIR, 'src', 'test', 'testWorkspace', 'workspace1')), name: 'workspace1', index: 0, @@ -286,4 +286,87 @@ suite('Settings Tests', () => { pythonConfigMock.verifyAll(); }); }); + + suite('checkIfConfigurationChanged tests', () => { + test('detects daemonStatusFile changes', () => { + const event = { + affectsConfiguration: (section: string) => section === 'mypy.daemonStatusFile', + } as any; + const result = checkIfConfigurationChanged(event, 'mypy'); + assert.isTrue(result); + }); + + test('returns false when unrelated setting changes', () => { + const event = { + affectsConfiguration: (_section: string) => false, + } as any; + const result = checkIfConfigurationChanged(event, 'mypy'); + assert.isFalse(result); + }); + }); + + suite('getWorkspaceSettings daemonStatusFile tests', () => { + let getConfigurationStub: sinon.SinonStub; + let getInterpreterDetailsStub: sinon.SinonStub; + let getWorkspaceFoldersStub: sinon.SinonStub; + let configMock: TypeMoq.IMock; + let pythonConfigMock: TypeMoq.IMock; + const workspace1: WorkspaceFolder = { + uri: Uri.file(path.join(EXTENSION_ROOT_DIR, 'src', 'test', 'testWorkspace', 'workspace1')), + name: 'workspace1', + index: 0, + }; + + setup(() => { + getConfigurationStub = sinon.stub(vscodeapi, 'getConfiguration'); + getInterpreterDetailsStub = sinon.stub(python, 'getInterpreterDetails'); + configMock = TypeMoq.Mock.ofType(); + pythonConfigMock = TypeMoq.Mock.ofType(); + getConfigurationStub.callsFake((namespace: string, _uri: Uri) => { + if (namespace.startsWith('mypy')) { + return configMock.object; + } + return pythonConfigMock.object; + }); + getInterpreterDetailsStub.resolves({ path: undefined }); + getWorkspaceFoldersStub = sinon.stub(vscodeapi, 'getWorkspaceFolders'); + getWorkspaceFoldersStub.returns([workspace1]); + }); + + teardown(() => { + sinon.restore(); + }); + + test('daemonStatusFile is included in settings with default empty string', async () => { + configMock.setup((c) => c.get('args', [])).returns(() => []); + configMock.setup((c) => c.get('path', [])).returns(() => []); + configMock.setup((c) => c.get('severity', TypeMoq.It.isAny())).returns(() => DEFAULT_SEVERITY); + configMock.setup((c) => c.get('importStrategy', 'useBundled')).returns(() => 'useBundled'); + configMock.setup((c) => c.get('showNotifications', 'off')).returns(() => 'off'); + configMock.setup((c) => c.get('cwd', TypeMoq.It.isAnyString())).returns(() => workspace1.uri.fsPath); + configMock.setup((c) => c.get('ignorePatterns', [])).returns(() => []); + configMock.setup((c) => c.get('daemonStatusFile', '')).returns(() => ''); + pythonConfigMock.setup((c) => c.get('analysis.extraPaths', [])).returns(() => []); + + const settings: ISettings = await getWorkspaceSettings('mypy', workspace1); + + assert.strictEqual(settings.daemonStatusFile, ''); + }); + + test('daemonStatusFile with custom value', async () => { + configMock.setup((c) => c.get('args', [])).returns(() => []); + configMock.setup((c) => c.get('path', [])).returns(() => []); + configMock.setup((c) => c.get('severity', TypeMoq.It.isAny())).returns(() => DEFAULT_SEVERITY); + configMock.setup((c) => c.get('importStrategy', 'useBundled')).returns(() => 'useBundled'); + configMock.setup((c) => c.get('showNotifications', 'off')).returns(() => 'off'); + configMock.setup((c) => c.get('cwd', TypeMoq.It.isAnyString())).returns(() => workspace1.uri.fsPath); + configMock.setup((c) => c.get('ignorePatterns', [])).returns(() => []); + configMock.setup((c) => c.get('daemonStatusFile', '')).returns(() => '/custom/dmypy_status.json'); + pythonConfigMock.setup((c) => c.get('analysis.extraPaths', [])).returns(() => []); + + const settings: ISettings = await getWorkspaceSettings('mypy', workspace1); + + assert.strictEqual(settings.daemonStatusFile, '/custom/dmypy_status.json'); + }); + }); });