diff --git a/docs/pytest-lsp/guide/language-client.rst b/docs/pytest-lsp/guide/language-client.rst index 775ccbf..b6f1fb8 100644 --- a/docs/pytest-lsp/guide/language-client.rst +++ b/docs/pytest-lsp/guide/language-client.rst @@ -90,6 +90,27 @@ Similar to ``window/logMessage`` above, the client records any :lsp:`window/show :start-at: @server.feature :end-at: return items +``window/workDoneProgress/create`` +---------------------------------- + +The client can respond to :lsp:`window/workDoneProgress/create` requests and handle associated :lsp:`$/progress` +notifications + +.. card:: test_server.py + + .. literalinclude:: ../../../lib/pytest-lsp/tests/examples/window-create-progress/t_server.py + :language: python + :start-at: @pytest.mark.asyncio + :end-before: @pytest.mark.asyncio + +.. card:: server.py + + .. literalinclude:: ../../../lib/pytest-lsp/tests/examples/window-create-progress/server.py + :language: python + :start-at: @server.command + :end-at: return + + ``workspace/configuration`` --------------------------- diff --git a/lib/pytest-lsp/changes/91.feature.rst b/lib/pytest-lsp/changes/91.feature.rst new file mode 100644 index 0000000..b0a673d --- /dev/null +++ b/lib/pytest-lsp/changes/91.feature.rst @@ -0,0 +1 @@ +pytest-lsp's ``LanguageClient`` is now able to handle ``window/workDoneProgress/create`` requests. diff --git a/lib/pytest-lsp/pytest_lsp/client.py b/lib/pytest-lsp/pytest_lsp/client.py index 042cd66..4698917 100644 --- a/lib/pytest-lsp/pytest_lsp/client.py +++ b/lib/pytest-lsp/pytest_lsp/client.py @@ -6,10 +6,12 @@ import sys import traceback import typing +import warnings from typing import Any from typing import Dict from typing import List from typing import Optional +from typing import Type from typing import Union from lsprotocol import types @@ -20,6 +22,7 @@ from pygls.lsp.client import BaseLanguageClient from pygls.protocol import default_converter +from .checks import LspSpecificationWarning from .protocol import LanguageClientProtocol if sys.version_info.minor < 9: @@ -47,8 +50,7 @@ def __init__(self, *args, configuration: Optional[Dict[str, Any]] = None, **kwar """The client's capabilities.""" self.shown_documents: List[types.ShowDocumentParams] = [] - """Used to keep track of the documents requested to be shown via a - ``window/showDocument`` request.""" + """Holds any received show document requests.""" self.messages: List[types.ShowMessageParams] = [] """Holds any received ``window/showMessage`` requests.""" @@ -57,7 +59,12 @@ def __init__(self, *args, configuration: Optional[Dict[str, Any]] = None, **kwar """Holds any received ``window/logMessage`` requests.""" self.diagnostics: Dict[str, List[types.Diagnostic]] = {} - """Used to hold any recieved diagnostics.""" + """Holds any recieved diagnostics.""" + + self.progress_reports: Dict[ + types.ProgressToken, List[types.ProgressParams] + ] = {} + """Holds any received progress updates.""" self.error: Optional[Exception] = None """Indicates if the client encountered an error.""" @@ -283,6 +290,42 @@ def publish_diagnostics( ): client.diagnostics[params.uri] = params.diagnostics + @client.feature(types.WINDOW_WORK_DONE_PROGRESS_CREATE) + def create_work_done_progress( + client: LanguageClient, params: types.WorkDoneProgressCreateParams + ): + if params.token in client.progress_reports: + # TODO: Send an error reponse to the client - might require changes + # to pygls... + warnings.warn( + f"Duplicate progress token: {params.token!r}", LspSpecificationWarning + ) + + client.progress_reports.setdefault(params.token, []) + return None + + @client.feature(types.PROGRESS) + def progress(client: LanguageClient, params: types.ProgressParams): + if params.token not in client.progress_reports: + warnings.warn( + f"Unknown progress token: {params.token!r}", LspSpecificationWarning + ) + + if not params.value: + return + + if (kind := params.value.get("kind", None)) == "begin": + type_: Type[Any] = types.WorkDoneProgressBegin + elif kind == "report": + type_ = types.WorkDoneProgressReport + elif kind == "end": + type_ = types.WorkDoneProgressEnd + else: + raise TypeError(f"Unknown progress kind: {kind!r}") + + value = client.protocol._converter.structure(params.value, type_) + client.progress_reports.setdefault(params.token, []).append(value) + @client.feature(types.WINDOW_LOG_MESSAGE) def log_message(client: LanguageClient, params: types.LogMessageParams): client.log_messages.append(params) diff --git a/lib/pytest-lsp/pytest_lsp/protocol.py b/lib/pytest-lsp/pytest_lsp/protocol.py index e849b9f..2068bf5 100644 --- a/lib/pytest-lsp/pytest_lsp/protocol.py +++ b/lib/pytest-lsp/pytest_lsp/protocol.py @@ -57,6 +57,8 @@ def _handle_notification(self, method_name, params): async def send_request_async(self, method, params=None): """Wrap pygls' ``send_request_async`` implementation. This will + - Check the params to see if they're compatible with the client's stated + capabilities - Check the result to see if it's compatible with the client's stated capabilities @@ -71,8 +73,11 @@ async def send_request_async(self, method, params=None): Returns ------- Any - The response's result + The result """ + check_params_against_client_capabilities( + self._server.capabilities, method, params + ) result = await super().send_request_async(method, params) check_result_against_client_capabilities( self._server.capabilities, method, result # type: ignore diff --git a/lib/pytest-lsp/tests/examples/window-create-progress/server.py b/lib/pytest-lsp/tests/examples/window-create-progress/server.py new file mode 100644 index 0000000..b2819fa --- /dev/null +++ b/lib/pytest-lsp/tests/examples/window-create-progress/server.py @@ -0,0 +1,64 @@ +from unittest.mock import Mock + +from lsprotocol import types +from pygls.server import LanguageServer + +server = LanguageServer("window-create-progress", "v1") + + +@server.command("do.progress") +async def do_progress(ls: LanguageServer, *args): + token = "a-token" + + await ls.progress.create_async(token) + + # Begin + ls.progress.begin( + token, + types.WorkDoneProgressBegin(title="Indexing", percentage=0), + ) + # Report + for i in range(1, 4): + ls.progress.report( + token, + types.WorkDoneProgressReport(message=f"{i * 25}%", percentage=i * 25), + ) + # End + ls.progress.end(token, types.WorkDoneProgressEnd(message="Finished")) + + return "a result" + + +@server.command("duplicate.progress") +async def duplicate_progress(ls: LanguageServer, *args): + token = "duplicate-token" + + # Need to stop pygls preventing us from using the progress API wrong. + ls.progress._check_token_registered = Mock() + await ls.progress.create_async(token) + + # pytest-lsp should return an error here. + await ls.progress.create_async(token) + + +@server.command("no.progress") +async def no_progress(ls: LanguageServer, *args): + token = "undefined-token" + + # Begin + ls.progress.begin( + token, + types.WorkDoneProgressBegin(title="Indexing", percentage=0, cancellable=False), + ) + # Report + for i in range(1, 4): + ls.progress.report( + token, + types.WorkDoneProgressReport(message=f"{i * 25}%", percentage=i * 25), + ) + # End + ls.progress.end(token, types.WorkDoneProgressEnd(message="Finished")) + + +if __name__ == "__main__": + server.start_io() diff --git a/lib/pytest-lsp/tests/examples/window-create-progress/t_server.py b/lib/pytest-lsp/tests/examples/window-create-progress/t_server.py new file mode 100644 index 0000000..f80a401 --- /dev/null +++ b/lib/pytest-lsp/tests/examples/window-create-progress/t_server.py @@ -0,0 +1,61 @@ +import sys + +import pytest +from lsprotocol import types + +import pytest_lsp +from pytest_lsp import ClientServerConfig +from pytest_lsp import LanguageClient +from pytest_lsp import LspSpecificationWarning + + +@pytest_lsp.fixture( + config=ClientServerConfig(server_command=[sys.executable, "server.py"]), +) +async def client(lsp_client: LanguageClient): + # Setup + params = types.InitializeParams(capabilities=types.ClientCapabilities()) + await lsp_client.initialize_session(params) + + yield + + # Teardown + await lsp_client.shutdown_session() + + +@pytest.mark.asyncio +async def test_progress(client: LanguageClient): + result = await client.workspace_execute_command_async( + params=types.ExecuteCommandParams(command="do.progress") + ) + + assert result == "a result" + + progress = client.progress_reports["a-token"] + assert progress == [ + types.WorkDoneProgressBegin(title="Indexing", percentage=0), + types.WorkDoneProgressReport(message="25%", percentage=25), + types.WorkDoneProgressReport(message="50%", percentage=50), + types.WorkDoneProgressReport(message="75%", percentage=75), + types.WorkDoneProgressEnd(message="Finished"), + ] + + +@pytest.mark.asyncio +async def test_duplicate_progress(client: LanguageClient): + with pytest.warns( + LspSpecificationWarning, match="Duplicate progress token: 'duplicate-token'" + ): + await client.workspace_execute_command_async( + params=types.ExecuteCommandParams(command="duplicate.progress") + ) + + +@pytest.mark.asyncio +async def test_unknown_progress(client: LanguageClient): + with pytest.warns( + LspSpecificationWarning, match="Unknown progress token: 'undefined-token'" + ): + await client.workspace_execute_command_async( + params=types.ExecuteCommandParams(command="no.progress") + ) diff --git a/lib/pytest-lsp/tests/test_examples.py b/lib/pytest-lsp/tests/test_examples.py index 79a505e..956d4d2 100644 --- a/lib/pytest-lsp/tests/test_examples.py +++ b/lib/pytest-lsp/tests/test_examples.py @@ -25,17 +25,28 @@ def setup_test(pytester: pytest.Pytester, example_name: str): @pytest.mark.parametrize( "name, expected", [ - ("diagnostics", dict(passed=1)), - ("getting-started", dict(passed=1)), - ("fixture-passthrough", dict(passed=1)), - ("parameterised-clients", dict(passed=2)), - ("window-log-message", dict(passed=1)), - ("window-show-document", dict(passed=1)), - ("window-show-message", dict(passed=1)), - ("workspace-configuration", dict(passed=1, warnings=1)), + pytest.param("diagnostics", dict(passed=1), id="diagnostics"), + pytest.param("getting-started", dict(passed=1), id="getting-started"), + pytest.param("fixture-passthrough", dict(passed=1), id="fixture-passthrough"), + pytest.param( + "parameterised-clients", dict(passed=2), id="parameterised-clients" + ), + pytest.param("window-log-message", dict(passed=1), id="window-log-message"), + pytest.param( + "window-create-progress", + dict(passed=3), + id="window-create-progress", + ), + pytest.param("window-show-document", dict(passed=1), id="window-show-document"), + pytest.param("window-show-message", dict(passed=1), id="window-show-message"), + pytest.param( + "workspace-configuration", + dict(passed=1, warnings=1), + id="workspace-configuration", + ), ], ) -def test_documentation_examples(pytester: pytest.Pytester, name: str, expected: dict): +def test_examples(pytester: pytest.Pytester, name: str, expected: dict): """Ensure that the examples included in the documentation work as expected.""" setup_test(pytester, name)