From 98ca1d9297026ecc9f0a16d553b3377aecb9ca67 Mon Sep 17 00:00:00 2001 From: Hendrik Dumith Louzada Date: Fri, 7 Nov 2025 16:45:51 -0300 Subject: [PATCH 1/4] feat: update plugin and server configuration management --- pylsp/_utils.py | 5 +- pylsp/config/config.py | 203 ------- pylsp/config/flake8_conf.py | 64 --- pylsp/config/plugin.py | 31 ++ pylsp/config/pycodestyle_conf.py | 34 -- pylsp/config/source.py | 42 +- pylsp/hookspecs.py | 7 +- pylsp/lsp.py | 115 ---- pylsp/python_lsp.py | 921 ------------------------------- pylsp/server/__init__.py | 65 ++- pylsp/server/protocol.py | 110 +++- pylsp/server/settings.py | 131 +++++ pylsp/server/workspace.py | 87 ++- pylsp/workspace.py | 713 ------------------------ 14 files changed, 419 insertions(+), 2109 deletions(-) delete mode 100644 pylsp/config/config.py delete mode 100644 pylsp/config/flake8_conf.py create mode 100644 pylsp/config/plugin.py delete mode 100644 pylsp/config/pycodestyle_conf.py delete mode 100644 pylsp/lsp.py delete mode 100644 pylsp/python_lsp.py create mode 100644 pylsp/server/settings.py delete mode 100644 pylsp/workspace.py diff --git a/pylsp/_utils.py b/pylsp/_utils.py index a29e382e..dbdbff82 100644 --- a/pylsp/_utils.py +++ b/pylsp/_utils.py @@ -11,6 +11,7 @@ import sys import threading import time +from glob import glob from typing import Any, Iterable, List, Optional import docstring_to_markdown @@ -96,7 +97,9 @@ def find_parents(root, path, names): # Split the relative by directory, generate all the parent directories, then check each of them. # This avoids running a loop that has different base-cases for unix/windows # e.g. /a/b and /a/b/c/d/e.py -> ['/a/b', 'c', 'd'] - dirs = [root] + os.path.relpath(os.path.dirname(path), root).split(os.path.sep) + if os.path.isfile(path): + path = os.path.dirname(path) + dirs = [root] + os.path.relpath(path, root).split(os.path.sep) # Search each of /a/b/c, /a/b, /a while dirs: diff --git a/pylsp/config/config.py b/pylsp/config/config.py deleted file mode 100644 index 7b201824..00000000 --- a/pylsp/config/config.py +++ /dev/null @@ -1,203 +0,0 @@ -# Copyright 2017-2020 Palantir Technologies, Inc. -# Copyright 2021- Python Language Server Contributors. - -import logging -import sys -from collections.abc import Mapping, Sequence -from functools import lru_cache -from typing import Union - -import pluggy -from pluggy._hooks import HookImpl - -from pylsp import PYLSP, _utils, hookspecs, uris - -# See compatibility note on `group` keyword: -# https://docs.python.org/3/library/importlib.metadata.html#entry-points -if sys.version_info < (3, 10): # pragma: no cover - from importlib_metadata import entry_points -else: # pragma: no cover - from importlib.metadata import entry_points - - -log = logging.getLogger(__name__) - -# Sources of config, first source overrides next source -DEFAULT_CONFIG_SOURCES = ["pycodestyle"] - - -class PluginManager(pluggy.PluginManager): - def _hookexec( - self, - hook_name: str, - methods: Sequence[HookImpl], - kwargs: Mapping[str, object], - firstresult: bool, - ) -> Union[object, list[object]]: - # called from all hookcaller instances. - # enable_tracing will set its own wrapping function at self._inner_hookexec - try: - return self._inner_hookexec(hook_name, methods, kwargs, firstresult) - except Exception as e: - log.warning(f"Failed to load hook {hook_name}: {e}", exc_info=True) - return [] - - -class Config: - def __init__(self, root_uri, init_opts, process_id, capabilities) -> None: - self._root_path = uris.to_fs_path(root_uri) - self._root_uri = root_uri - self._init_opts = init_opts - self._process_id = process_id - self._capabilities = capabilities - - self._settings = {} - self._plugin_settings = {} - - self._config_sources = {} - try: - from .flake8_conf import Flake8Config - - self._config_sources["flake8"] = Flake8Config(self._root_path) - except ImportError: - pass - try: - from .pycodestyle_conf import PyCodeStyleConfig - - self._config_sources["pycodestyle"] = PyCodeStyleConfig(self._root_path) - except ImportError: - pass - - self._pm = PluginManager(PYLSP) - self._pm.trace.root.setwriter(log.debug) - self._pm.enable_tracing() - self._pm.add_hookspecs(hookspecs) - - # Pluggy will skip loading a plugin if it throws a DistributionNotFound exception. - # However I don't want all plugins to have to catch ImportError and re-throw. So here we'll filter - # out any entry points that throw ImportError assuming one or more of their dependencies isn't present. - for entry_point in entry_points(group=PYLSP): - try: - entry_point.load() - except Exception as e: - log.info( - "Failed to load %s entry point '%s': %s", PYLSP, entry_point.name, e - ) - self._pm.set_blocked(entry_point.name) - - # Load the entry points into pluggy, having blocked any failing ones. - # Despite the API name, recent Pluggy versions will use ``importlib_metadata``. - self._pm.load_setuptools_entrypoints(PYLSP) - - for name, plugin in self._pm.list_name_plugin(): - if plugin is not None: - log.info("Loaded pylsp plugin %s from %s", name, plugin) - - for plugin_conf in self._pm.hook.pylsp_settings(config=self): - self._plugin_settings = _utils.merge_dicts( - self._plugin_settings, plugin_conf - ) - - self._plugin_settings = _utils.merge_dicts( - self._plugin_settings, self._init_opts.get("pylsp", {}) - ) - - self._update_disabled_plugins() - - @property - def disabled_plugins(self): - return self._disabled_plugins - - @property - def plugin_manager(self): - return self._pm - - @property - def init_opts(self): - return self._init_opts - - @property - def root_uri(self): - return self._root_uri - - @property - def process_id(self): - return self._process_id - - @property - def capabilities(self): - return self._capabilities - - @lru_cache(maxsize=32) - def settings(self, document_path=None): - """Settings are constructed from a few sources: - - 1. User settings, found in user's home directory - 2. Plugin settings, reported by PyLS plugins - 3. LSP settings, given to us from didChangeConfiguration - 4. Project settings, found in config files in the current project. - - Since this function is nondeterministic, it is important to call - settings.cache_clear() when the config is updated - """ - settings = {} - sources = self._settings.get("configurationSources", DEFAULT_CONFIG_SOURCES) - - # Plugin configuration - settings = _utils.merge_dicts(settings, self._plugin_settings) - - # LSP configuration - settings = _utils.merge_dicts(settings, self._settings) - - # User configuration - for source_name in reversed(sources): - source = self._config_sources.get(source_name) - if not source: - continue - source_conf = source.user_config() - log.debug( - "Got user config from %s: %s", source.__class__.__name__, source_conf - ) - settings = _utils.merge_dicts(settings, source_conf) - - # Project configuration - for source_name in reversed(sources): - source = self._config_sources.get(source_name) - if not source: - continue - source_conf = source.project_config(document_path or self._root_path) - log.debug( - "Got project config from %s: %s", source.__class__.__name__, source_conf - ) - settings = _utils.merge_dicts(settings, source_conf) - - log.debug("With configuration: %s", settings) - - return settings - - def find_parents(self, path, names): - root_path = uris.to_fs_path(self._root_uri) - return _utils.find_parents(root_path, path, names) - - def plugin_settings(self, plugin, document_path=None): - return ( - self.settings(document_path=document_path) - .get("plugins", {}) - .get(plugin, {}) - ) - - def update(self, settings) -> None: - """Recursively merge the given settings into the current settings.""" - self.settings.cache_clear() - self._settings = settings - log.info("Updated settings to %s", self._settings) - self._update_disabled_plugins() - - def _update_disabled_plugins(self) -> None: - # All plugins default to enabled - self._disabled_plugins = [ - plugin - for name, plugin in self.plugin_manager.list_name_plugin() - if not self.settings().get("plugins", {}).get(name, {}).get("enabled", True) - ] - log.info("Disabled plugins: %s", self._disabled_plugins) diff --git a/pylsp/config/flake8_conf.py b/pylsp/config/flake8_conf.py deleted file mode 100644 index 74258709..00000000 --- a/pylsp/config/flake8_conf.py +++ /dev/null @@ -1,64 +0,0 @@ -# Copyright 2017-2020 Palantir Technologies, Inc. -# Copyright 2021- Python Language Server Contributors. - -import logging -import os - -from pylsp._utils import find_parents - -from .source import ConfigSource - -log = logging.getLogger(__name__) - -CONFIG_KEY = "flake8" -PROJECT_CONFIGS = [".flake8", "setup.cfg", "tox.ini"] - -OPTIONS = [ - # mccabe - ("max-complexity", "plugins.mccabe.threshold", int), - # pycodestyle - ("exclude", "plugins.pycodestyle.exclude", list), - ("filename", "plugins.pycodestyle.filename", list), - ("hang-closing", "plugins.pycodestyle.hangClosing", bool), - ("ignore", "plugins.pycodestyle.ignore", list), - ("max-line-length", "plugins.pycodestyle.maxLineLength", int), - ("indent-size", "plugins.pycodestyle.indentSize", int), - ("select", "plugins.pycodestyle.select", list), - # flake8 - ("exclude", "plugins.flake8.exclude", list), - ("extend-ignore", "plugins.flake8.extendIgnore", list), - ("extend-select", "plugins.flake8.extendSelect", list), - ("filename", "plugins.flake8.filename", list), - ("hang-closing", "plugins.flake8.hangClosing", bool), - ("ignore", "plugins.flake8.ignore", list), - ("max-complexity", "plugins.flake8.maxComplexity", int), - ("max-line-length", "plugins.flake8.maxLineLength", int), - ("indent-size", "plugins.flake8.indentSize", int), - ("select", "plugins.flake8.select", list), - ("per-file-ignores", "plugins.flake8.perFileIgnores", list), -] - - -class Flake8Config(ConfigSource): - """Parse flake8 configurations.""" - - def user_config(self): - config_file = self._user_config_file() - config = self.read_config_from_files([config_file]) - return self.parse_config(config, CONFIG_KEY, OPTIONS) - - def _user_config_file(self): - if self.is_windows: - return os.path.expanduser("~\\.flake8") - return os.path.join(self.xdg_home, "flake8") - - def project_config(self, document_path): - files = find_parents(self.root_path, document_path, PROJECT_CONFIGS) - config = self.read_config_from_files(files) - return self.parse_config(config, CONFIG_KEY, OPTIONS) - - @classmethod - def _parse_list_opt(cls, string): - if string.startswith("\n"): - return [s.strip().rstrip(",") for s in string.split("\n") if s.strip()] - return [s.strip() for s in string.split(",") if s.strip()] diff --git a/pylsp/config/plugin.py b/pylsp/config/plugin.py new file mode 100644 index 00000000..e59bf754 --- /dev/null +++ b/pylsp/config/plugin.py @@ -0,0 +1,31 @@ +# Copyright 2017-2020 Palantir Technologies, Inc. +# Copyright 2021- Python Language Server Contributors. + +from __future__ import annotations + +import logging +from collections.abc import Mapping, Sequence + +import pluggy +from pluggy import HookImpl + +log = logging.getLogger(__name__) + + +class PluginManager(pluggy.PluginManager): + def __init__(self, project_name: str): + super().__init__(project_name) + + def _hookexec( + self, + hook_name: str, + methods: Sequence[HookImpl], + kwargs: Mapping[str, object], + firstresult: bool, + ): + try: + return self._inner_hookexec(hook_name, methods, kwargs, firstresult) + except Exception as e: + log.warning(f"Failed to load hook {hook_name}: {e}", exc_info=True) + return [] + diff --git a/pylsp/config/pycodestyle_conf.py b/pylsp/config/pycodestyle_conf.py deleted file mode 100644 index ed15a802..00000000 --- a/pylsp/config/pycodestyle_conf.py +++ /dev/null @@ -1,34 +0,0 @@ -# Copyright 2017-2020 Palantir Technologies, Inc. -# Copyright 2021- Python Language Server Contributors. - -import pycodestyle - -from pylsp._utils import find_parents - -from .source import ConfigSource - -CONFIG_KEY = "pycodestyle" -USER_CONFIGS = [pycodestyle.USER_CONFIG] if pycodestyle.USER_CONFIG else [] -PROJECT_CONFIGS = ["pycodestyle.cfg", "setup.cfg", "tox.ini"] - -OPTIONS = [ - ("exclude", "plugins.pycodestyle.exclude", list), - ("filename", "plugins.pycodestyle.filename", list), - ("hang-closing", "plugins.pycodestyle.hangClosing", bool), - ("ignore", "plugins.pycodestyle.ignore", list), - ("max-line-length", "plugins.pycodestyle.maxLineLength", int), - ("indent-size", "plugins.pycodestyle.indentSize", int), - ("select", "plugins.pycodestyle.select", list), - ("aggressive", "plugins.pycodestyle.aggressive", int), -] - - -class PyCodeStyleConfig(ConfigSource): - def user_config(self): - config = self.read_config_from_files(USER_CONFIGS) - return self.parse_config(config, CONFIG_KEY, OPTIONS) - - def project_config(self, document_path): - files = find_parents(self.root_path, document_path, PROJECT_CONFIGS) - config = self.read_config_from_files(files) - return self.parse_config(config, CONFIG_KEY, OPTIONS) diff --git a/pylsp/config/source.py b/pylsp/config/source.py index 8ffc8b71..5f1aac71 100644 --- a/pylsp/config/source.py +++ b/pylsp/config/source.py @@ -1,10 +1,14 @@ # Copyright 2017-2020 Palantir Technologies, Inc. # Copyright 2021- Python Language Server Contributors. - import configparser import logging import os import sys +from collections.abc import Mapping, MutableMapping +from functools import cached_property +from copy import deepcopy + +from pylsp._utils import find_parents log = logging.getLogger(__name__) @@ -12,20 +16,36 @@ class ConfigSource: """Base class for implementing a config source.""" + XDG_CONFIG_HOME = os.environ.get( + "XDG_CONFIG_HOME", os.path.expanduser("~/.config") + ) + + USER_CONFIGS = [] + """list: User config files to search for.""" + + PROJECT_CONFIGS = [] + """list: Project config files to search for.""" + + OPTIONS = [] + """list: Options to parse from the config files.""" + + CONFIG_KEY = "" + """str: The config section key to look for when parsing.""" + def __init__(self, root_path) -> None: self.root_path = root_path - self.is_windows = sys.platform == "win32" - self.xdg_home = os.environ.get( - "XDG_CONFIG_HOME", os.path.expanduser("~/.config") - ) - def user_config(self) -> None: + @cached_property + def user_config(self): """Return user-level (i.e. home directory) configuration.""" - raise NotImplementedError() + return self.read_config_from_files(self.USER_CONFIGS) - def project_config(self, document_path) -> None: - """Return project-level (i.e. workspace directory) configuration.""" - raise NotImplementedError() + def project_config(self, document_path): + """Return project-level (i.e. workspace directory) configuration. + """ + return self.read_config_from_files( + find_parents(self.root_path, document_path, self.PROJECT_CONFIGS) + ) @classmethod def read_config_from_files(cls, files): @@ -34,7 +54,7 @@ def read_config_from_files(cls, files): if os.path.exists(filename) and not os.path.isdir(filename): config.read(filename) - return config + return cls.parse_config(config, cls.CONFIG_KEY, cls.OPTIONS) @classmethod def parse_config(cls, config, key, options): diff --git a/pylsp/hookspecs.py b/pylsp/hookspecs.py index cf97c745..73ef546c 100644 --- a/pylsp/hookspecs.py +++ b/pylsp/hookspecs.py @@ -1,8 +1,11 @@ # Copyright 2017-2020 Palantir Technologies, Inc. # Copyright 2021- Python Language Server Contributors. +from __future__ import annotations from pylsp import hookspec +from pylsp.config.source import ConfigSource + @hookspec def pylsp_code_actions(config, workspace, document, range, context): @@ -126,8 +129,8 @@ def pylsp_rename(config, workspace, document, position, new_name) -> None: @hookspec -def pylsp_settings(config) -> None: - pass +def pylsp_settings() -> dict | ConfigSource: + ... @hookspec(firstresult=True) diff --git a/pylsp/lsp.py b/pylsp/lsp.py deleted file mode 100644 index 7b3f02ee..00000000 --- a/pylsp/lsp.py +++ /dev/null @@ -1,115 +0,0 @@ -# Copyright 2017-2020 Palantir Technologies, Inc. -# Copyright 2021- Python Language Server Contributors. - -"""Some Language Server Protocol constants - -https://github.com/Microsoft/language-server-protocol/blob/master/protocol.md -""" - - -class CompletionItemKind: - Text = 1 - Method = 2 - Function = 3 - Constructor = 4 - Field = 5 - Variable = 6 - Class = 7 - Interface = 8 - Module = 9 - Property = 10 - Unit = 11 - Value = 12 - Enum = 13 - Keyword = 14 - Snippet = 15 - Color = 16 - File = 17 - Reference = 18 - Folder = 19 - EnumMember = 20 - Constant = 21 - Struct = 22 - Event = 23 - Operator = 24 - TypeParameter = 25 - - -class DocumentHighlightKind: - Text = 1 - Read = 2 - Write = 3 - - -class DiagnosticSeverity: - Error = 1 - Warning = 2 - Information = 3 - Hint = 4 - - -class DiagnosticTag: - Unnecessary = 1 - Deprecated = 2 - - -class InsertTextFormat: - PlainText = 1 - Snippet = 2 - - -class MessageType: - Error = 1 - Warning = 2 - Info = 3 - Log = 4 - - -class SymbolKind: - File = 1 - Module = 2 - Namespace = 3 - Package = 4 - Class = 5 - Method = 6 - Property = 7 - Field = 8 - Constructor = 9 - Enum = 10 - Interface = 11 - Function = 12 - Variable = 13 - Constant = 14 - String = 15 - Number = 16 - Boolean = 17 - Array = 18 - - -class TextDocumentSyncKind: - NONE = 0 - FULL = 1 - INCREMENTAL = 2 - - -class NotebookCellKind: - Markup = 1 - Code = 2 - - -# https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#errorCodes -class ErrorCodes: - ParseError = -32700 - InvalidRequest = -32600 - MethodNotFound = -32601 - InvalidParams = -32602 - InternalError = -32603 - jsonrpcReservedErrorRangeStart = -32099 - ServerNotInitialized = -32002 - UnknownErrorCode = -32001 - jsonrpcReservedErrorRangeEnd = -32000 - lspReservedErrorRangeStart = -32899 - ServerCancelled = -32802 - ContentModified = -32801 - RequestCancelled = -32800 - lspReservedErrorRangeEnd = -32800 diff --git a/pylsp/python_lsp.py b/pylsp/python_lsp.py deleted file mode 100644 index a2640666..00000000 --- a/pylsp/python_lsp.py +++ /dev/null @@ -1,921 +0,0 @@ -# Copyright 2017-2020 Palantir Technologies, Inc. -# Copyright 2021- Python Language Server Contributors. - -import logging -import os -import socketserver -import threading -import uuid -from functools import partial -from typing import Any - -try: - import ujson as json -except Exception: - import json - -from pylsp_jsonrpc.dispatchers import MethodDispatcher -from pylsp_jsonrpc.endpoint import Endpoint -from pylsp_jsonrpc.streams import JsonRpcStreamReader, JsonRpcStreamWriter - -from . import _utils, lsp, uris -from ._version import __version__ -from .config import config -from .workspace import Cell, Document, Notebook, Workspace - -log = logging.getLogger(__name__) - - -LINT_DEBOUNCE_S = 0.5 # 500 ms -PARENT_PROCESS_WATCH_INTERVAL = 10 # 10 s -MAX_WORKERS = 64 -PYTHON_FILE_EXTENSIONS = (".py", ".pyi") -CONFIG_FILEs = ("pycodestyle.cfg", "setup.cfg", "tox.ini", ".flake8") - - -class _StreamHandlerWrapper(socketserver.StreamRequestHandler): - """A wrapper class that is used to construct a custom handler class.""" - - delegate = None - - def setup(self) -> None: - super().setup() - self.delegate = self.DELEGATE_CLASS(self.rfile, self.wfile) - - def handle(self) -> None: - try: - self.delegate.start() - except OSError as e: - if os.name == "nt": - # Catch and pass on ConnectionResetError when parent process - # dies - if isinstance(e, WindowsError) and e.winerror == 10054: - pass - - self.SHUTDOWN_CALL() - - -def start_tcp_lang_server(bind_addr, port, check_parent_process, handler_class) -> None: - if not issubclass(handler_class, PythonLSPServer): - raise ValueError("Handler class must be an instance of PythonLSPServer") - - def shutdown_server(check_parent_process, *args): - if check_parent_process: - log.debug("Shutting down server") - # Shutdown call must be done on a thread, to prevent deadlocks - stop_thread = threading.Thread(target=server.shutdown) - stop_thread.start() - - # Construct a custom wrapper class around the user's handler_class - wrapper_class = type( - handler_class.__name__ + "Handler", - (_StreamHandlerWrapper,), - { - "DELEGATE_CLASS": partial( - handler_class, check_parent_process=check_parent_process - ), - "SHUTDOWN_CALL": partial(shutdown_server, check_parent_process), - }, - ) - - server = socketserver.TCPServer( - (bind_addr, port), wrapper_class, bind_and_activate=False - ) - server.allow_reuse_address = True - - try: - server.server_bind() - server.server_activate() - log.info("Serving %s on (%s, %s)", handler_class.__name__, bind_addr, port) - server.serve_forever() - finally: - log.info("Shutting down") - server.server_close() - - -def start_io_lang_server(rfile, wfile, check_parent_process, handler_class) -> None: - if not issubclass(handler_class, PythonLSPServer): - raise ValueError("Handler class must be an instance of PythonLSPServer") - log.info("Starting %s IO language server", handler_class.__name__) - server = handler_class(rfile, wfile, check_parent_process) - server.start() - - -def start_ws_lang_server(port, check_parent_process, handler_class) -> None: - if not issubclass(handler_class, PythonLSPServer): - raise ValueError("Handler class must be an instance of PythonLSPServer") - - # imports needed only for websockets based server - try: - import asyncio - from concurrent.futures import ThreadPoolExecutor - - import websockets - except ImportError as e: - raise ImportError( - "websocket modules missing. Please run: pip install 'python-lsp-server[websockets]'" - ) from e - - with ThreadPoolExecutor(max_workers=10) as tpool: - send_queue = None - loop = None - - async def pylsp_ws(websocket): - log.debug("Creating LSP object") - - # creating a partial function and suppling the websocket connection - response_handler = partial(send_message, websocket=websocket) - - # Not using default stream reader and writer. - # Instead using a consumer based approach to handle processed requests - pylsp_handler = handler_class( - rx=None, - tx=None, - consumer=response_handler, - check_parent_process=check_parent_process, - ) - - async for message in websocket: - try: - log.debug("consuming payload and feeding it to LSP handler") - request = json.loads(message) - loop = asyncio.get_running_loop() - await loop.run_in_executor(tpool, pylsp_handler.consume, request) - except Exception as e: - log.exception("Failed to process request %s, %s", message, str(e)) - - def send_message(message, websocket): - """Handler to send responses of processed requests to respective web socket clients""" - try: - payload = json.dumps(message, ensure_ascii=False) - loop.call_soon_threadsafe(send_queue.put_nowait, (payload, websocket)) - except Exception as e: - log.exception("Failed to write message %s, %s", message, str(e)) - - async def run_server(): - nonlocal send_queue, loop - send_queue = asyncio.Queue() - loop = asyncio.get_running_loop() - - async with websockets.serve(pylsp_ws, port=port): - while 1: - # Wait until payload is available for sending - payload, websocket = await send_queue.get() - await websocket.send(payload) - - asyncio.run(run_server()) - - -class PythonLSPServer(MethodDispatcher): - """Implementation of the Microsoft VSCode Language Server Protocol - https://github.com/Microsoft/language-server-protocol/blob/master/versions/protocol-1-x.md - """ - - def __init__( - self, rx, tx, check_parent_process=False, consumer=None, *, endpoint_cls=None - ) -> None: - self.workspace = None - self.config = None - self.root_uri = None - self.watching_thread = None - self.workspaces = {} - self.uri_workspace_mapper = {} - - self._check_parent_process = check_parent_process - - if rx is not None: - self._jsonrpc_stream_reader = JsonRpcStreamReader(rx) - else: - self._jsonrpc_stream_reader = None - - if tx is not None: - self._jsonrpc_stream_writer = JsonRpcStreamWriter(tx) - else: - self._jsonrpc_stream_writer = None - - endpoint_cls = endpoint_cls or Endpoint - - # if consumer is None, it is assumed that the default streams-based approach is being used - if consumer is None: - self._endpoint = endpoint_cls( - self, self._jsonrpc_stream_writer.write, max_workers=MAX_WORKERS - ) - else: - self._endpoint = endpoint_cls(self, consumer, max_workers=MAX_WORKERS) - - self._dispatchers = [] - self._shutdown = False - - def start(self) -> None: - """Entry point for the server.""" - self._jsonrpc_stream_reader.listen(self._endpoint.consume) - - def consume(self, message) -> None: - """Entry point for consumer based server. Alternative to stream listeners.""" - # assuming message will be JSON - self._endpoint.consume(message) - - def __getitem__(self, item): - """Override getitem to fallback through multiple dispatchers.""" - if self._shutdown and item != "exit": - # exit is the only allowed method during shutdown - log.debug("Ignoring non-exit method during shutdown: %s", item) - item = "invalid_request_after_shutdown" - - try: - return super().__getitem__(item) - except KeyError: - # Fallback through extra dispatchers - for dispatcher in self._dispatchers: - try: - return dispatcher[item] - except KeyError: - continue - - raise KeyError() - - def m_shutdown(self, **_kwargs) -> None: - for workspace in self.workspaces.values(): - workspace.close() - self._hook("pylsp_shutdown") - self._shutdown = True - - def m_invalid_request_after_shutdown(self, **_kwargs): - return { - "error": { - "code": lsp.ErrorCodes.InvalidRequest, - "message": "Requests after shutdown are not valid", - } - } - - def m_exit(self, **_kwargs) -> None: - self._endpoint.shutdown() - if self._jsonrpc_stream_reader is not None: - self._jsonrpc_stream_reader.close() - if self._jsonrpc_stream_writer is not None: - self._jsonrpc_stream_writer.close() - - def _match_uri_to_workspace(self, uri): - workspace_uri = _utils.match_uri_to_workspace(uri, self.workspaces) - return self.workspaces.get(workspace_uri, self.workspace) - - def _hook(self, hook_name, doc_uri=None, **kwargs): - """Calls hook_name and returns a list of results from all registered handlers""" - workspace = self._match_uri_to_workspace(doc_uri) - doc = workspace.get_document(doc_uri) if doc_uri else None - hook_handlers = self.config.plugin_manager.subset_hook_caller( - hook_name, self.config.disabled_plugins - ) - return hook_handlers( - config=self.config, workspace=workspace, document=doc, **kwargs - ) - - def capabilities(self): - server_capabilities = { - "codeActionProvider": True, - "codeLensProvider": { - "resolveProvider": False, # We may need to make this configurable - }, - "completionProvider": { - "resolveProvider": True, # We could know everything ahead of time, but this takes time to transfer - "triggerCharacters": ["."], - }, - "documentFormattingProvider": True, - "documentHighlightProvider": True, - "documentRangeFormattingProvider": True, - "documentSymbolProvider": True, - "definitionProvider": True, - "typeDefinitionProvider": True, - "executeCommandProvider": { - "commands": flatten(self._hook("pylsp_commands")) - }, - "hoverProvider": True, - "referencesProvider": True, - "renameProvider": True, - "foldingRangeProvider": True, - "signatureHelpProvider": {"triggerCharacters": ["(", ",", "="]}, - "textDocumentSync": { - "change": lsp.TextDocumentSyncKind.INCREMENTAL, - "save": { - "includeText": True, - }, - "openClose": True, - }, - "notebookDocumentSync": { - "notebookSelector": [{"cells": [{"language": "python"}]}] - }, - "workspace": { - "workspaceFolders": {"supported": True, "changeNotifications": True} - }, - "experimental": merge(self._hook("pylsp_experimental_capabilities")), - } - log.info("Server capabilities: %s", server_capabilities) - return server_capabilities - - def m_initialize( - self, - processId=None, - rootUri=None, - rootPath=None, - initializationOptions=None, - workspaceFolders=None, - **_kwargs, - ): - log.debug( - "Language server initialized with %s %s %s %s", - processId, - rootUri, - rootPath, - initializationOptions, - ) - if rootUri is None: - rootUri = uris.from_fs_path(rootPath) if rootPath is not None else "" - - self.workspaces.pop(self.root_uri, None) - self.root_uri = rootUri - self.config = config.Config( - rootUri, - initializationOptions or {}, - processId, - _kwargs.get("capabilities", {}), - ) - self.workspace = Workspace(rootUri, self._endpoint, self.config) - self.workspaces[rootUri] = self.workspace - if workspaceFolders: - for folder in workspaceFolders: - uri = folder["uri"] - if uri == rootUri: - # Already created - continue - workspace_config = config.Config( - uri, - self.config._init_opts, - self.config._process_id, - self.config._capabilities, - ) - workspace_config.update(self.config._settings) - self.workspaces[uri] = Workspace(uri, self._endpoint, workspace_config) - - self._dispatchers = self._hook("pylsp_dispatchers") - self._hook("pylsp_initialize") - - if ( - self._check_parent_process - and processId is not None - and self.watching_thread is None - ): - - def watch_parent_process(pid): - # exit when the given pid is not alive - if not _utils.is_process_alive(pid): - log.info("parent process %s is not alive, exiting!", pid) - self.m_exit() - else: - threading.Timer( - PARENT_PROCESS_WATCH_INTERVAL, watch_parent_process, args=[pid] - ).start() - - self.watching_thread = threading.Thread( - target=watch_parent_process, args=(processId,) - ) - self.watching_thread.daemon = True - self.watching_thread.start() - # Get our capabilities - return { - "capabilities": self.capabilities(), - "serverInfo": { - "name": "pylsp", - "version": __version__, - }, - } - - def m_initialized(self, **_kwargs) -> None: - self._hook("pylsp_initialized") - - def code_actions(self, doc_uri: str, range: dict, context: dict): - return flatten( - self._hook("pylsp_code_actions", doc_uri, range=range, context=context) - ) - - def code_lens(self, doc_uri): - return flatten(self._hook("pylsp_code_lens", doc_uri)) - - def completions(self, doc_uri, position): - workspace = self._match_uri_to_workspace(doc_uri) - document = workspace.get_document(doc_uri) - ignored_names = None - if isinstance(document, Cell): - # We need to get the ignored names from the whole notebook document - notebook_document = workspace.get_maybe_document(document.notebook_uri) - ignored_names = notebook_document.jedi_names(doc_uri) - completions = self._hook( - "pylsp_completions", doc_uri, position=position, ignored_names=ignored_names - ) - return {"isIncomplete": False, "items": flatten(completions)} - - def completion_item_resolve(self, completion_item): - doc_uri = completion_item.get("data", {}).get("doc_uri", None) - return self._hook( - "pylsp_completion_item_resolve", doc_uri, completion_item=completion_item - ) - - def definitions(self, doc_uri, position): - return flatten(self._hook("pylsp_definitions", doc_uri, position=position)) - - def type_definition(self, doc_uri, position): - return self._hook("pylsp_type_definition", doc_uri, position=position) - - def document_symbols(self, doc_uri): - return flatten(self._hook("pylsp_document_symbols", doc_uri)) - - def document_did_save(self, doc_uri): - return self._hook("pylsp_document_did_save", doc_uri) - - def execute_command(self, command, arguments): - return self._hook("pylsp_execute_command", command=command, arguments=arguments) - - def format_document(self, doc_uri, options): - return lambda: self._hook("pylsp_format_document", doc_uri, options=options) - - def format_range(self, doc_uri, range, options): - return self._hook("pylsp_format_range", doc_uri, range=range, options=options) - - def highlight(self, doc_uri, position): - return ( - flatten(self._hook("pylsp_document_highlight", doc_uri, position=position)) - or None - ) - - def hover(self, doc_uri, position): - return self._hook("pylsp_hover", doc_uri, position=position) or {"contents": ""} - - @_utils.debounce(LINT_DEBOUNCE_S, keyed_by="doc_uri") - def lint(self, doc_uri, is_saved) -> None: - # Since we're debounced, the document may no longer be open - workspace = self._match_uri_to_workspace(doc_uri) - document_object = workspace.documents.get(doc_uri, None) - if isinstance(document_object, Document): - self._lint_text_document( - doc_uri, workspace, is_saved, document_object.version - ) - elif isinstance(document_object, Notebook): - self._lint_notebook_document(document_object, workspace) - - def _lint_text_document( - self, doc_uri, workspace, is_saved, doc_version=None - ) -> None: - workspace.publish_diagnostics( - doc_uri, - flatten(self._hook("pylsp_lint", doc_uri, is_saved=is_saved)), - doc_version, - ) - - def _lint_notebook_document(self, notebook_document, workspace) -> None: - """ - Lint a notebook document. - - This is a bit more complicated than linting a text document, because we need to - send the entire notebook document to the pylsp_lint hook, but we need to send - the diagnostics back to the client on a per-cell basis. - """ - - # First, we create a temp TextDocument that represents the whole notebook - # contents. We'll use this to send to the pylsp_lint hook. - random_uri = str(uuid.uuid4()) - - # cell_list helps us map the diagnostics back to the correct cell later. - cell_list: list[dict[str, Any]] = [] - - offset = 0 - total_source = "" - for cell in notebook_document.cells: - cell_uri = cell["document"] - cell_document = workspace.get_cell_document(cell_uri) - - num_lines = cell_document.line_count - - data = { - "uri": cell_uri, - "line_start": offset, - "line_end": offset + num_lines - 1, - "source": cell_document.source, - } - - cell_list.append(data) - if offset == 0: - total_source = cell_document.source - else: - total_source += "\n" + cell_document.source - - offset += num_lines - - workspace.put_document(random_uri, total_source) - - try: - document_diagnostics = flatten( - self._hook("pylsp_lint", random_uri, is_saved=True) - ) - - # Now we need to map the diagnostics back to the correct cell and publish them. - # Note: this is O(n*m) in the number of cells and diagnostics, respectively. - for cell in cell_list: - cell_diagnostics = [] - for diagnostic in document_diagnostics: - start_line = diagnostic["range"]["start"]["line"] - end_line = diagnostic["range"]["end"]["line"] - - if start_line > cell["line_end"] or end_line < cell["line_start"]: - continue - diagnostic["range"]["start"]["line"] = ( - start_line - cell["line_start"] - ) - diagnostic["range"]["end"]["line"] = end_line - cell["line_start"] - cell_diagnostics.append(diagnostic) - - workspace.publish_diagnostics(cell["uri"], cell_diagnostics) - finally: - workspace.rm_document(random_uri) - - def references(self, doc_uri, position, exclude_declaration): - return flatten( - self._hook( - "pylsp_references", - doc_uri, - position=position, - exclude_declaration=exclude_declaration, - ) - ) - - def rename(self, doc_uri, position, new_name): - return self._hook("pylsp_rename", doc_uri, position=position, new_name=new_name) - - def signature_help(self, doc_uri, position): - return self._hook("pylsp_signature_help", doc_uri, position=position) - - def folding(self, doc_uri): - return flatten(self._hook("pylsp_folding_range", doc_uri)) - - def m_completion_item__resolve(self, **completionItem): - return self.completion_item_resolve(completionItem) - - def m_notebook_document__did_open( - self, notebookDocument=None, cellTextDocuments=None, **_kwargs - ) -> None: - workspace = self._match_uri_to_workspace(notebookDocument["uri"]) - workspace.put_notebook_document( - notebookDocument["uri"], - notebookDocument["notebookType"], - cells=notebookDocument["cells"], - version=notebookDocument.get("version"), - metadata=notebookDocument.get("metadata"), - ) - for cell in cellTextDocuments or []: - workspace.put_cell_document( - cell["uri"], - notebookDocument["uri"], - cell["languageId"], - cell["text"], - version=cell.get("version"), - ) - self.lint(notebookDocument["uri"], is_saved=True) - - def m_notebook_document__did_close( - self, notebookDocument=None, cellTextDocuments=None, **_kwargs - ) -> None: - workspace = self._match_uri_to_workspace(notebookDocument["uri"]) - for cell in cellTextDocuments or []: - workspace.publish_diagnostics(cell["uri"], []) - workspace.rm_document(cell["uri"]) - workspace.rm_document(notebookDocument["uri"]) - - def m_notebook_document__did_change( - self, notebookDocument=None, change=None, **_kwargs - ) -> None: - """ - Changes to the notebook document. - - This could be one of the following: - 1. Notebook metadata changed - 2. Cell(s) added - 3. Cell(s) deleted - 4. Cell(s) data changed - 4.1 Cell metadata changed - 4.2 Cell source changed - """ - workspace = self._match_uri_to_workspace(notebookDocument["uri"]) - - if change.get("metadata"): - # Case 1 - workspace.update_notebook_metadata( - notebookDocument["uri"], change.get("metadata") - ) - - cells = change.get("cells") - if cells: - # Change to cells - structure = cells.get("structure") - if structure: - # Case 2 or 3 - notebook_cell_array_change = structure["array"] - start = notebook_cell_array_change["start"] - cell_delete_count = notebook_cell_array_change["deleteCount"] - if cell_delete_count == 0: - # Case 2 - # Cell documents - for cell_document in structure["didOpen"]: - workspace.put_cell_document( - cell_document["uri"], - notebookDocument["uri"], - cell_document["languageId"], - cell_document["text"], - cell_document.get("version"), - ) - # Cell metadata which is added to Notebook - workspace.add_notebook_cells( - notebookDocument["uri"], - notebook_cell_array_change["cells"], - start, - ) - else: - # Case 3 - # Cell documents - for cell_document in structure["didClose"]: - workspace.rm_document(cell_document["uri"]) - workspace.publish_diagnostics(cell_document["uri"], []) - # Cell metadata which is removed from Notebook - workspace.remove_notebook_cells( - notebookDocument["uri"], start, cell_delete_count - ) - - data = cells.get("data") - if data: - # Case 4.1 - for cell in data: - # update NotebookDocument.cells properties - pass - - text_content = cells.get("textContent") - if text_content: - # Case 4.2 - for cell in text_content: - cell_uri = cell["document"]["uri"] - # Even though the protocol says that `changes` is an array, we assume that it's always a single - # element array that contains the last change to the cell source. - workspace.update_document(cell_uri, cell["changes"][0]) - self.lint(notebookDocument["uri"], is_saved=True) - - def m_text_document__did_close(self, textDocument=None, **_kwargs) -> None: - workspace = self._match_uri_to_workspace(textDocument["uri"]) - workspace.publish_diagnostics(textDocument["uri"], []) - workspace.rm_document(textDocument["uri"]) - - def m_text_document__did_open(self, textDocument=None, **_kwargs) -> None: - workspace = self._match_uri_to_workspace(textDocument["uri"]) - workspace.put_document( - textDocument["uri"], - textDocument["text"], - version=textDocument.get("version"), - ) - self._hook("pylsp_document_did_open", textDocument["uri"]) - self.lint(textDocument["uri"], is_saved=True) - - def m_text_document__did_change( - self, contentChanges=None, textDocument=None, **_kwargs - ) -> None: - workspace = self._match_uri_to_workspace(textDocument["uri"]) - for change in contentChanges: - workspace.update_document( - textDocument["uri"], change, version=textDocument.get("version") - ) - self.lint(textDocument["uri"], is_saved=False) - - def m_text_document__did_save(self, textDocument=None, **_kwargs) -> None: - self.lint(textDocument["uri"], is_saved=True) - self.document_did_save(textDocument["uri"]) - - def m_text_document__code_action( - self, textDocument=None, range=None, context=None, **_kwargs - ): - return self.code_actions(textDocument["uri"], range, context) - - def m_text_document__code_lens(self, textDocument=None, **_kwargs): - return self.code_lens(textDocument["uri"]) - - def _cell_document__completion(self, cellDocument, position=None, **_kwargs): - workspace = self._match_uri_to_workspace(cellDocument.notebook_uri) - notebookDocument = workspace.get_maybe_document(cellDocument.notebook_uri) - if notebookDocument is None: - raise ValueError("Invalid notebook document") - - cell_data = notebookDocument.cell_data() - - # Concatenate all cells to be a single temporary document - total_source = "\n".join(data["source"] for data in cell_data.values()) - with workspace.temp_document(total_source) as temp_uri: - # update position to be the position in the temp document - if position is not None: - position["line"] += cell_data[cellDocument.uri]["line_start"] - - completions = self.completions(temp_uri, position) - - # Translate temp_uri locations to cell document locations - for item in completions.get("items", []): - if item.get("data", {}).get("doc_uri") == temp_uri: - item["data"]["doc_uri"] = cellDocument.uri - - # Copy LAST_JEDI_COMPLETIONS to cell document so that completionItem/resolve will work - tempDocument = workspace.get_document(temp_uri) - cellDocument.shared_data["LAST_JEDI_COMPLETIONS"] = ( - tempDocument.shared_data.get("LAST_JEDI_COMPLETIONS", None) - ) - - return completions - - def m_text_document__completion(self, textDocument=None, position=None, **_kwargs): - # textDocument here is just a dict with a uri - workspace = self._match_uri_to_workspace(textDocument["uri"]) - document = workspace.get_document(textDocument["uri"]) - if isinstance(document, Cell): - return self._cell_document__completion(document, position, **_kwargs) - return self.completions(textDocument["uri"], position) - - def _cell_document__definition(self, cellDocument, position=None, **_kwargs): - workspace = self._match_uri_to_workspace(cellDocument.notebook_uri) - notebookDocument = workspace.get_maybe_document(cellDocument.notebook_uri) - if notebookDocument is None: - raise ValueError("Invalid notebook document") - - cell_data = notebookDocument.cell_data() - - # Concatenate all cells to be a single temporary document - total_source = "\n".join(data["source"] for data in cell_data.values()) - with workspace.temp_document(total_source) as temp_uri: - # update position to be the position in the temp document - if position is not None: - position["line"] += cell_data[cellDocument.uri]["line_start"] - - definitions = self.definitions(temp_uri, position) - - # Translate temp_uri locations to cell document locations - for definition in definitions: - if definition["uri"] == temp_uri: - # Find the cell the start line is in and adjust the uri and line numbers - for cell_uri, data in cell_data.items(): - if ( - data["line_start"] - <= definition["range"]["start"]["line"] - <= data["line_end"] - ): - definition["uri"] = cell_uri - definition["range"]["start"]["line"] -= data["line_start"] - definition["range"]["end"]["line"] -= data["line_start"] - break - - return definitions - - def m_text_document__definition(self, textDocument=None, position=None, **_kwargs): - # textDocument here is just a dict with a uri - workspace = self._match_uri_to_workspace(textDocument["uri"]) - document = workspace.get_document(textDocument["uri"]) - if isinstance(document, Cell): - return self._cell_document__definition(document, position, **_kwargs) - return self.definitions(textDocument["uri"], position) - - def m_text_document__type_definition( - self, textDocument=None, position=None, **_kwargs - ): - return self.type_definition(textDocument["uri"], position) - - def m_text_document__document_highlight( - self, textDocument=None, position=None, **_kwargs - ): - return self.highlight(textDocument["uri"], position) - - def m_text_document__hover(self, textDocument=None, position=None, **_kwargs): - return self.hover(textDocument["uri"], position) - - def m_text_document__document_symbol(self, textDocument=None, **_kwargs): - return self.document_symbols(textDocument["uri"]) - - def m_text_document__formatting(self, textDocument=None, options=None, **_kwargs): - return self.format_document(textDocument["uri"], options) - - def m_text_document__rename( - self, textDocument=None, position=None, newName=None, **_kwargs - ): - return self.rename(textDocument["uri"], position, newName) - - def m_text_document__folding_range(self, textDocument=None, **_kwargs): - return self.folding(textDocument["uri"]) - - def m_text_document__range_formatting( - self, textDocument=None, range=None, options=None, **_kwargs - ): - return self.format_range(textDocument["uri"], range, options) - - def m_text_document__references( - self, textDocument=None, position=None, context=None, **_kwargs - ): - exclude_declaration = not context["includeDeclaration"] - return self.references(textDocument["uri"], position, exclude_declaration) - - def m_text_document__signature_help( - self, textDocument=None, position=None, **_kwargs - ): - return self.signature_help(textDocument["uri"], position) - - def m_workspace__did_change_configuration(self, settings=None) -> None: - if self.config is not None: - self.config.update((settings or {}).get("pylsp", {})) - for workspace in self.workspaces.values(): - workspace.update_config(settings) - self._hook("pylsp_workspace_configuration_changed") - for doc_uri in workspace.documents: - self.lint(doc_uri, is_saved=False) - - def m_workspace__did_change_workspace_folders(self, event=None, **_kwargs): - if event is None: - return - added = event.get("added", []) - removed = event.get("removed", []) - - for removed_info in removed: - if "uri" in removed_info: - removed_uri = removed_info["uri"] - self.workspaces.pop(removed_uri, None) - - for added_info in added: - if "uri" in added_info: - added_uri = added_info["uri"] - workspace_config = config.Config( - added_uri, - self.config._init_opts, - self.config._process_id, - self.config._capabilities, - ) - workspace_config.update(self.config._settings) - self.workspaces[added_uri] = Workspace( - added_uri, self._endpoint, workspace_config - ) - - root_workspace_removed = any( - removed_info["uri"] == self.root_uri for removed_info in removed - ) - workspace_added = len(added) > 0 and "uri" in added[0] - if root_workspace_removed and workspace_added: - added_uri = added[0]["uri"] - self.root_uri = added_uri - new_root_workspace = self.workspaces[added_uri] - self.config = new_root_workspace._config - self.workspace = new_root_workspace - elif root_workspace_removed: - # NOTE: Removing the root workspace can only happen when the server - # is closed, thus the else condition of this if can never happen. - if self.workspaces: - log.debug("Root workspace deleted!") - available_workspaces = sorted(self.workspaces) - first_workspace = available_workspaces[0] - new_root_workspace = self.workspaces[first_workspace] - self.root_uri = first_workspace - self.config = new_root_workspace._config - self.workspace = new_root_workspace - - # Migrate documents that are on the root workspace and have a better - # match now - doc_uris = list(self.workspace._docs.keys()) - for uri in doc_uris: - doc = self.workspace._docs.pop(uri) - new_workspace = self._match_uri_to_workspace(uri) - new_workspace._docs[uri] = doc - - def m_workspace__did_change_watched_files(self, changes=None, **_kwargs): - changed_py_files = set() - config_changed = False - for d in changes or []: - if d["uri"].endswith(PYTHON_FILE_EXTENSIONS): - changed_py_files.add(d["uri"]) - elif d["uri"].endswith(CONFIG_FILEs): - config_changed = True - - if config_changed: - self.config.settings.cache_clear() - elif not changed_py_files: - # Only externally changed python files and lint configs may result in changed diagnostics. - return - - for workspace in self.workspaces.values(): - for doc_uri in workspace.documents: - # Changes in doc_uri are already handled by m_text_document__did_save - if doc_uri not in changed_py_files: - self.lint(doc_uri, is_saved=False) - - def m_workspace__execute_command(self, command=None, arguments=None): - return self.execute_command(command, arguments) - - -def flatten(list_of_lists): - return [item for lst in list_of_lists for item in lst] - - -def merge(list_of_dicts): - return {k: v for dictionary in list_of_dicts for k, v in dictionary.items()} diff --git a/pylsp/server/__init__.py b/pylsp/server/__init__.py index 37104f31..2fb59906 100644 --- a/pylsp/server/__init__.py +++ b/pylsp/server/__init__.py @@ -81,30 +81,56 @@ async def initialized(ls: LanguageServer, params: lsptyp.InitializedParams): """Handle the initialized notification.""" # Call the initialized hook await ls.lsp.call_hook("pylsp_initialized") + # Dynamically register file watchers for plugin-declared configs + try: + watch_globs = ls.lsp.workspace.config.watcher_globs() + if watch_globs: + watchers = [ + lsptyp.FileSystemWatcher(glob_pattern=glob) + for glob in watch_globs + ] + reg = lsptyp.Registration( + id="pylsp-watched-files", + method=lsptyp.WORKSPACE_DID_CHANGE_WATCHED_FILES, + register_options=lsptyp.DidChangeWatchedFilesRegistrationOptions(watchers=watchers), + ) + await ls.lsp.register_capability_async(lsptyp.RegistrationParams(registrations=[reg])) + except Exception: + logger.debug("Failed to register watched files", exc_info=True) @LSP_SERVER.feature(lsptyp.WORKSPACE_DID_CHANGE_CONFIGURATION) async def workspace_did_change_configuration( - ls: LanguageServer, params: lsptyp.WorkspaceConfigurationParams + ls: LanguageServer, params: lsptyp.DidChangeConfigurationParams ): - """Handle the workspace did change configuration notification.""" - for config_item in params.items: - ls.workspace.config.update({config_item.scope_uri: config_item.section}) - # TODO: Check configuration update is valid and supports this type of update + """Handle workspace configuration changes: update settings and notify plugins.""" + # Update global/workspace config from client settings + ls.lsp.workspace.config.update_workspace_settings(params.settings) await ls.lsp.call_hook("pylsp_workspace_configuration_changed") + # Trigger re-lint on open documents + try: + for uri in list(ls.lsp.workspace._docs.keys()): + await ls.lsp.lint_text_document(uri) + except Exception: + logger.debug("Failed to re-lint after config change", exc_info=True) @LSP_SERVER.feature(lsptyp.WORKSPACE_DID_CHANGE_WATCHED_FILES) def workspace_did_change_watched_files( ls: LanguageServer, params: lsptyp.DidChangeWatchedFilesParams ): - """Handle the workspace did change watched files notification.""" - for change in params.changes: - if change.uri.endswith(CONFIG_FILES): - ls.workspace.config.settings.cache_clear() - break - - # TODO: check if necessary to link files not handled by textDocument/Open + """On watched config file changes, refresh settings and re-lint.""" + try: + # A simple strategy: if any watched file changed, clear caches by updating settings + if params.changes: + ls.lsp.workspace.config.update_workspace_settings({}) + # Notify plugins + ls.lsp._server.loop.create_task(ls.lsp.call_hook("pylsp_workspace_configuration_changed")) + # Re-lint open docs + for uri in list(ls.lsp.workspace._docs.keys()): + ls.lsp._server.loop.create_task(ls.lsp.lint_text_document(uri)) + except Exception: + logger.debug("Failed to process watched files change", exc_info=True) @LSP_SERVER.feature(lsptyp.WORKSPACE_EXECUTE_COMMAND) @@ -151,7 +177,7 @@ async def notebook_document_did_close( ls: LanguageServer, params: lsptyp.DidCloseNotebookDocumentParams ): """Handle the notebook document did close notification.""" - await ls.lsp.cancel_tasks(params.notebook_document.uri) + # No background tasks to cancel in current implementation @LSP_SERVER.feature(lsptyp.TEXT_DOCUMENT_DID_OPEN) @@ -159,7 +185,8 @@ async def text_document_did_open( ls: LanguageServer, params: lsptyp.DidOpenTextDocumentParams ): """Handle the text document did open notification.""" - await ls.lsp.lint_text_document(params.text_document.uri) + await ls.lsp.call_hook("pylsp_document_did_open", doc_uri=params.text_document.uri) + await ls.lsp.call_hook("pylsp_lint", doc_uri=params.text_document.uri, is_saved=True) @LSP_SERVER.feature(lsptyp.TEXT_DOCUMENT_DID_CHANGE) @@ -175,7 +202,7 @@ async def text_document_did_save( ls: LanguageServer, params: lsptyp.DidSaveTextDocumentParams ): """Handle the text document did save notification.""" - await ls.lsp.lint_text_document(params.text_document.uri) + await ls.lsp.call_hook("pylsp_lint", doc_uri=params.text_document.uri, is_saved=True) await ls.workspace.save(params.text_document.uri) @@ -184,7 +211,7 @@ async def text_document_did_close( ls: LanguageServer, params: lsptyp.DidCloseTextDocumentParams ): """Handle the text document did close notification.""" - await ls.lsp.cancel_tasks(params.text_document.uri) + # No background tasks to cancel in current implementation @LSP_SERVER.feature(lsptyp.TEXT_DOCUMENT_CODE_ACTION) @@ -194,7 +221,7 @@ async def text_document_code_action( """Handle the text document code action request.""" actions: typ.List[lsptyp.Command | lsptyp.CodeAction] | None = flatten( await ls.lsp.call_hook( - "pylsp_code_action", + "pylsp_code_actions", params.text_document.uri, range=params.range, context=params.context, @@ -226,10 +253,10 @@ async def text_document_completion( """Handle the text document completion request.""" completions: typ.List[lsptyp.CompletionItem] | None = flatten( await ls.lsp.call_hook( - "pylsp_completion", + "pylsp_completions", params.text_document.uri, position=params.position, - context=params.context, + ignored_names=None, work_done_token=params.work_done_token, ) ) diff --git a/pylsp/server/protocol.py b/pylsp/server/protocol.py index 468e4d50..4f1ac15a 100644 --- a/pylsp/server/protocol.py +++ b/pylsp/server/protocol.py @@ -17,8 +17,10 @@ from pygls.uris import from_fs_path from pylsp import PYLSP, hookspecs -from pylsp.config.config import PluginManager +from pylsp.config.plugin import PluginManager +from pylsp.config.source import ConfigSource from pylsp.server.workspace import Workspace +from pylsp.server.settings import ServerConfig logger = logging.getLogger(__name__) @@ -27,12 +29,13 @@ class LangageServerProtocol(protocol.LanguageServerProtocol): """Custom features implementation for the Python Language Server.""" def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) self._pm = PluginManager(PYLSP) if logger.level <= logging.DEBUG: self._pm.trace.root.setwriter(logger.debug) self._pm.enable_tracing() self._pm.add_hookspecs(hookspecs) + self._config: ServerConfig | None = None + super().__init__(*args, **kwargs) @property def plugin_manager(self) -> PluginManager: @@ -82,7 +85,7 @@ def lsp_initialize(self, params: InitializeParams) -> InitializeResult: if root_path is not None and root_uri is None: root_uri = from_fs_path(root_path) - # Initialize the workspace + # Initialize the workspace and server configuration workspace_folders = params.workspace_folders or [] self._workspace = Workspace( self._server, @@ -92,8 +95,37 @@ def lsp_initialize(self, params: InitializeParams) -> InitializeResult: self.server_capabilities.position_encoding, ) + # Create configuration manager (global + per-workspace) + self._config = ServerConfig( + protocol=self, + root_uri=root_uri, + init_options=self.initialization_options, + ) + # Attach to workspace for plugin access + self.workspace.attach_config(self._config) + self.trace = TraceValues.Off + # Collect config sources from pylsp_settings hook (list of ConfigSource classes) + try: + plugin_settings = self.call_hook_sync("pylsp_settings") or [] + instances: list[ConfigSource] = [] + for setting in plugin_settings: + if isinstance(setting, dict): + # Direct settings dict provided by a hook implementation. + self._config.update_workspace_settings(setting) + else: + # Assume a ConfigSource class; instantiate with root URI. + try: + instances.append(setting(self.workspace.root_uri or "")) # type: ignore[arg-type] + except Exception: + logger.debug( + "Failed instantiating config source %s", setting, exc_info=True + ) + self._config.set_config_sources(instances) + except Exception: + logger.debug("Failed collecting pylsp_settings", exc_info=True) + return InitializeResult( capabilities=self.server_capabilities, server_info=self.server_info, @@ -120,28 +152,78 @@ async def call_hook( else: doc = None - workspace_folder = ( - self.workspace.get_document_folder(doc_uri) if doc_uri else None - ) - - folder_uri = ( - workspace_folder.uri if workspace_folder else self.workspace._root_uri - ) + # Determine target workspace folder (for workspace-scoped settings) + workspace_folder = self.workspace.get_document_folder(doc_uri) if doc_uri else None + folder_uri = workspace_folder.uri if workspace_folder else self.workspace.root_uri + # Get a hook caller, filtering disabled plugins hook_handlers_caller = self.plugin_manager.subset_hook_caller( - hook_name, self.workspace.config.disabled_plugins + hook_name, + self.workspace.config.disabled_plugins if self.workspace.config else set(), ) + # Build config view for the target folder + config_view = self._config.with_folder(folder_uri) if self._config else None + if work_done_token is not None: await self.progress.create_async(work_done_token) + self.progress.begin( + work_done_token, + typlsp.WorkDoneProgressBegin( + title=hook_name, + cancellable=False, # TODO: Add support for cancellable hooks + ), + ) - return await self._server.loop.run_in_executor( + result = await self._server.loop.run_in_executor( self._server.thread_pool_executor, partial( hook_handlers_caller, - lsp=self, - workspace=folder_uri, + config=config_view, + workspace=self.workspace, document=doc, **kwargs, ), ) + + if work_done_token is not None: + self.progress.end( + work_done_token, + typlsp.WorkDoneProgressEnd(), + ) + + return result + + def call_hook_sync( + self, + hook_name: str, + doc_uri: str | None = None, + **kwargs, + ): + """Synchronous variant for early initialization calls (e.g., pylsp_settings).""" + if doc_uri: + doc = self.workspace.get_text_document(doc_uri) + else: + doc = None + workspace_folder = self.workspace.get_document_folder(doc_uri) if doc_uri else None + folder_uri = workspace_folder.uri if workspace_folder else self.workspace.root_uri + hook_handlers_caller = self.plugin_manager.subset_hook_caller( + hook_name, + self.workspace.config.disabled_plugins if self.workspace.config else set(), + ) + config_view = self._config.with_folder(folder_uri) if self._config else None + return hook_handlers_caller( + config=config_view, + workspace=self.workspace, + document=doc, + **kwargs, + ) + + async def lint_text_document(self, doc_uri: str) -> None: + """Lint the given text document.""" + await self.call_hook("pylsp_lint", doc_uri=doc_uri, is_saved=False) + + async def lint_notebook_document(self, doc_uri: str) -> None: + """Lint the given notebook document (treat as saved).""" + # Plugins expect pylsp_lint; pygls manages notebook docs internally. + await self.call_hook("pylsp_lint", doc_uri=doc_uri, is_saved=True) diff --git a/pylsp/server/settings.py b/pylsp/server/settings.py new file mode 100644 index 00000000..55f427b2 --- /dev/null +++ b/pylsp/server/settings.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +import logging +from copy import deepcopy +import typing as typ + +from lsprotocol import types as lsptyp +from pylsp.config.source import ConfigSource + +if typ.TYPE_CHECKING: + from pylsp.server.protocol import LangageServerProtocol + + +logger = logging.getLogger(__name__) + + +class ServerConfig: + """Configuration manager with global and workspace-scoped settings. + + Exposes a subset of the legacy Config API used by plugins: + - plugin_settings(name, document_path=None) + - settings() + - capabilities + """ + + def __init__(self, protocol: LangageServerProtocol, root_uri: str | None, init_options: dict[str, typ.Any]): + self._protocol = protocol + self._root_uri = root_uri + # Global and per-folder runtime settings coming from client + self._global_settings: dict[str, typ.Any] = init_options.get("pylsp", {}) + self._workspace_settings: dict[str, dict[str, typ.Any]] = {} + self._current_folder: str | None = None + # populated after pylsp_settings hooks + self._config_sources: list[ConfigSource] = [] + + # ---- views ---- + def with_folder(self, folder_uri: str | None) -> "ServerConfig": + view = deepcopy(self) + view._current_folder = folder_uri + return view + + # ---- properties ---- + @property + def capabilities(self) -> lsptyp.ClientCapabilities: + return self._protocol.client_capabilities + + @property + def disabled_plugins(self) -> set[str]: + cfg = self.settings() + plugins_cfg = cfg.get("plugins", {}) + disabled = set() + for name, vals in plugins_cfg.items(): + enabled = vals.get("enabled") + if enabled is False: + disabled.add(name) + return disabled + + # ---- public API used by plugins ---- + def settings(self) -> dict[str, typ.Any]: + """Return merged settings (global overridden by workspace).""" + if self._current_folder and self._current_folder in self._workspace_settings: + merged = deepcopy(self._global_settings) + merged.update(self._workspace_settings[self._current_folder]) + return merged + return deepcopy(self._global_settings) + + def plugin_settings(self, plugin_name: str, document_path: str | None = None) -> dict[str, typ.Any]: + """Return merged settings for a plugin. + + Merge order (lowest to highest precedence): + 1) User-level config files from plugin-provided ConfigSources + 2) Project-level config files (based on document_path) + 3) LSP settings from client: settings()['plugins'][plugin_name] + """ + result: dict[str, typ.Any] = {} + + # Iterate collected ConfigSource instances + for src in self._config_sources: + try: + # 1) user config + _merge_into(result, src.user_config) + # 2) project config (depends on document_path) + if document_path: + proj_cfg = src.project_config(document_path) + _merge_into(result, proj_cfg) + except Exception: + logger.debug("Failed to read config via %s", src.__class__.__name__, exc_info=True) + + # 3) client-provided settings + client_plugins = self.settings().get("plugins", {}) + client_plugin_cfg = client_plugins.get(plugin_name, {}) + _merge_into(result, client_plugin_cfg) + return result + + # ---- updates from LSP events ---- + def update_workspace_settings(self, settings: dict[str, typ.Any] | None, folder_uri: str | None = None) -> None: + if not settings: + return + # Clients usually send a top-level {"pylsp": {...}} + payload = settings.get("pylsp", settings) + if folder_uri: + ws = self._workspace_settings.get(folder_uri, {}) + _merge_into(ws, payload) + self._workspace_settings[folder_uri] = ws + else: + _merge_into(self._global_settings, payload) + + # ---- watchers ---- + def watcher_globs(self) -> list[str]: + """Return glob patterns derived from collected ConfigSource PROJECT_CONFIGS.""" + globs: set[str] = set() + for src in self._config_sources: + try: + for fname in getattr(src, "PROJECT_CONFIGS", []) or []: + globs.add(f"**/{fname}") + except Exception: + continue + return sorted(globs) + + def set_config_sources(self, sources: list[ConfigSource]) -> None: + self._config_sources = sources + + +def _merge_into(dst: dict[str, typ.Any], src: dict[str, typ.Any] | None) -> None: + if not src: + return + for k, v in src.items(): + if isinstance(v, dict) and isinstance(dst.get(k), dict): + _merge_into(dst[k], v) + else: + dst[k] = v diff --git a/pylsp/server/workspace.py b/pylsp/server/workspace.py index 9b988145..8e5fabed 100644 --- a/pylsp/server/workspace.py +++ b/pylsp/server/workspace.py @@ -3,14 +3,17 @@ import typing as typ from pathlib import Path +from collections.abc import Generator +from typing import Callable, Optional + +from lsprotocol import types as lsptyp from lsprotocol.types import WorkspaceFolder from pygls import uris, workspace from pygls.workspace.text_document import TextDocument -from pylsp.config.config import Config - if typ.TYPE_CHECKING: from pygls.server import LanguageServer + from pylsp.server.settings import ServerConfig class Workspace(workspace.Workspace): @@ -19,16 +22,7 @@ class Workspace(workspace.Workspace): def __init__(self, server: LanguageServer, *args, **kwargs): self._server = server super().__init__(*args, **kwargs) - self._config = Config( - self._root_uri, - self._server.lsp.initialization_options, - self._server.process_id, - self._server.server_capabilities, - ) - - @property - def config(self) -> Config: - return self._config + self._config = None def get_document_folder(self, doc_uri: str) -> WorkspaceFolder | None: """Get the workspace folder for a given document URI. @@ -73,6 +67,75 @@ def _create_text_document( position_codec=self._position_codec, ) + async def save(self, doc_uri: str) -> None: + """Save the document to disk. + + Args: + doc_uri (str): The document URI. + """ + document = self.get_text_document(doc_uri) + if document is None: + return + + path = uris.to_fs_path(doc_uri) + if path is None: + return + + with open(path, "w", encoding="utf-8") as f: + f.write(document.source) + + # ---- pylsp plugin compatibility helpers ---- + + @property + def config(self) -> ServerConfig: + return self._config + + @property + def root_uri(self) -> str | None: + return self._root_uri + + def attach_config(self, config: ServerConfig) -> None: + self._config = config + + def report_progress( + self, + title: str, + message: Optional[str] = None, + percentage: Optional[int] = None, + skip_token_initialization: bool = False, + ) -> Generator[Callable[[str, Optional[int]], None], None, None]: + """Context manager for progress reporting compatible with plugins.""" + token = f"pylsp:{title}" + if not skip_token_initialization: + try: + self._server.window_work_done_progress_create( # type: ignore[attr-defined] + lsptyp.WorkDoneProgressCreateParams(token=token) + ) + except Exception: + # Best-effort; some clients may not support it synchronously + pass + + begin = lsptyp.WorkDoneProgressBegin(title=title) + if message is not None: + begin.message = message + if percentage is not None: + begin.percentage = percentage + self._server.progress(token, begin) # type: ignore[attr-defined] + + def _progress(msg: str, pct: Optional[int] = None) -> None: + rep = lsptyp.WorkDoneProgressReport() + if msg: + rep.message = msg + if pct is not None: + rep.percentage = pct + self._server.progress(token, rep) # type: ignore[attr-defined] + + try: + yield _progress + finally: + self._server.progress(token, lsptyp.WorkDoneProgressEnd()) # type: ignore[attr-defined] + + class Document(TextDocument): def __init__(self, workspace: Workspace, *args, **kwargs): diff --git a/pylsp/workspace.py b/pylsp/workspace.py deleted file mode 100644 index 290b95ee..00000000 --- a/pylsp/workspace.py +++ /dev/null @@ -1,713 +0,0 @@ -# Copyright 2017-2020 Palantir Technologies, Inc. -# Copyright 2021- Python Language Server Contributors. - -import functools -import io -import logging -import os -import re -import uuid -from collections.abc import Generator -from contextlib import contextmanager -from threading import RLock -from typing import Callable, Optional - -import jedi - -from . import _utils, lsp, uris - -log = logging.getLogger(__name__) - -DEFAULT_AUTO_IMPORT_MODULES = ["numpy"] - -# TODO: this is not the best e.g. we capture numbers -RE_START_WORD = re.compile("[A-Za-z_0-9]*$") -RE_END_WORD = re.compile("^[A-Za-z_0-9]*") - - -def lock(method): - """Define an atomic region over a method.""" - - @functools.wraps(method) - def wrapper(self, *args, **kwargs): - with self._lock: - return method(self, *args, **kwargs) - - return wrapper - - -class Workspace: - M_PUBLISH_DIAGNOSTICS = "textDocument/publishDiagnostics" - M_PROGRESS = "$/progress" - M_INITIALIZE_PROGRESS = "window/workDoneProgress/create" - M_APPLY_EDIT = "workspace/applyEdit" - M_SHOW_MESSAGE = "window/showMessage" - M_LOG_MESSAGE = "window/logMessage" - - def __init__(self, root_uri, endpoint, config=None) -> None: - self._config = config - self._root_uri = root_uri - self._endpoint = endpoint - self._root_uri_scheme = uris.urlparse(self._root_uri)[0] - self._root_path = uris.to_fs_path(self._root_uri) - self._docs = {} - - # Cache jedi environments - self._environments = {} - - # Whilst incubating, keep rope private - self.__rope = None - self.__rope_config = None - self.__rope_autoimport = None - - def _rope_autoimport( - self, - rope_config: Optional, - memory: bool = False, - ): - from rope.contrib.autoimport.sqlite import AutoImport - - if self.__rope_autoimport is None: - project = self._rope_project_builder(rope_config) - self.__rope_autoimport = AutoImport(project, memory=memory) - return self.__rope_autoimport - - def _rope_project_builder(self, rope_config): - from rope.base.project import Project - - # TODO: we could keep track of dirty files and validate only those - if self.__rope is None or self.__rope_config != rope_config: - rope_folder = rope_config.get("ropeFolder") - if "ropeFolder" in rope_config: - self.__rope = Project(self._root_path, ropefolder=rope_folder) - else: - self.__rope = Project(self._root_path) - self.__rope.prefs.set( - "extension_modules", rope_config.get("extensionModules", []) - ) - self.__rope.prefs.set("ignore_syntax_errors", True) - self.__rope.prefs.set("ignore_bad_imports", True) - self.__rope.validate() - return self.__rope - - @property - def documents(self): - return self._docs - - @property - def root_path(self): - return self._root_path - - @property - def root_uri(self): - return self._root_uri - - def is_local(self): - return (self._root_uri_scheme in ["", "file"]) and os.path.exists( - self._root_path - ) - - def get_document(self, doc_uri): - """Return a managed document if-present, else create one pointing at disk. - - See https://github.com/Microsoft/language-server-protocol/issues/177 - """ - return self._docs.get(doc_uri) or self._create_document(doc_uri) - - def get_cell_document(self, doc_uri): - return self._docs.get(doc_uri) - - def get_maybe_document(self, doc_uri): - return self._docs.get(doc_uri) - - def put_document(self, doc_uri, source, version=None) -> None: - self._docs[doc_uri] = self._create_document( - doc_uri, source=source, version=version - ) - - def put_notebook_document( - self, doc_uri, notebook_type, cells, version=None, metadata=None - ) -> None: - self._docs[doc_uri] = self._create_notebook_document( - doc_uri, notebook_type, cells, version, metadata - ) - - @contextmanager - def temp_document(self, source, path=None) -> None: - if path is None: - path = self.root_path - uri = uris.from_fs_path(os.path.join(path, str(uuid.uuid4()))) - try: - self.put_document(uri, source) - yield uri - finally: - self.rm_document(uri) - - def add_notebook_cells(self, doc_uri, cells, start) -> None: - self._docs[doc_uri].add_cells(cells, start) - - def remove_notebook_cells(self, doc_uri, start, delete_count) -> None: - self._docs[doc_uri].remove_cells(start, delete_count) - - def update_notebook_metadata(self, doc_uri, metadata) -> None: - self._docs[doc_uri].metadata = metadata - - def put_cell_document( - self, doc_uri, notebook_uri, language_id, source, version=None - ) -> None: - self._docs[doc_uri] = self._create_cell_document( - doc_uri, notebook_uri, language_id, source, version - ) - - def rm_document(self, doc_uri) -> None: - self._docs.pop(doc_uri) - - def update_document(self, doc_uri, change, version=None) -> None: - self._docs[doc_uri].apply_change(change) - self._docs[doc_uri].version = version - - def update_config(self, settings): - self._config.update((settings or {}).get("pylsp", {})) - for doc_uri in self.documents: - if isinstance(document := self.get_document(doc_uri), Notebook): - # Notebook documents don't have a config. The config is - # handled at the cell level. - return - document.update_config(settings) - - def apply_edit(self, edit): - return self._endpoint.request(self.M_APPLY_EDIT, {"edit": edit}) - - def publish_diagnostics(self, doc_uri, diagnostics, doc_version=None) -> None: - params = { - "uri": doc_uri, - "diagnostics": diagnostics, - } - - if doc_version: - params["version"] = doc_version - - self._endpoint.notify( - self.M_PUBLISH_DIAGNOSTICS, - params=params, - ) - - @contextmanager - def report_progress( - self, - title: str, - message: Optional[str] = None, - percentage: Optional[int] = None, - skip_token_initialization: bool = False, - ) -> Generator[Callable[[str, Optional[int]], None], None, None]: - """ - Report progress to the editor / client. - - ``skip_token_initialization` is necessary due to some current - limitations of our LSP implementation. When `report_progress` - is used from a synchronous LSP handler, the token initialization - will time out because we can't receive the response. - - Many editors will still correctly show the progress messages though, which - is why we are giving progress users the option to skip the initialization - of the progress token. - """ - if self._config: - client_supports_progress_reporting = self._config.capabilities.get( - "window", {} - ).get("workDoneProgress", False) - else: - client_supports_progress_reporting = False - - if client_supports_progress_reporting: - token = self._progress_begin( - title, message, percentage, skip_token_initialization - ) - - def progress_message( - message: str, percentage: Optional[int] = None - ) -> None: - self._progress_report(token, message, percentage) - - try: - yield progress_message - finally: - self._progress_end(token) - - return - - # FALLBACK: - # If the client doesn't support progress reporting, we have a dummy method - # for the caller to use. - def dummy_progress_message( - message: str, percentage: Optional[int] = None - ) -> None: - pass - - yield dummy_progress_message - - def _progress_begin( - self, - title: str, - message: Optional[str] = None, - percentage: Optional[int] = None, - skip_token_initialization: bool = False, - ) -> str: - token = str(uuid.uuid4()) - - if not skip_token_initialization: - try: - self._endpoint.request( - self.M_INITIALIZE_PROGRESS, {"token": token} - ).result(timeout=1.0) - except Exception: - log.warning( - "There was an error while trying to initialize progress reporting." - "Likely progress reporting was used in a synchronous LSP handler, " - "which is not supported by progress reporting yet. " - "To prevent waiting for the timeout you can set " - "`skip_token_initialization=True`. " - "Not every editor will show progress then, but many will.", - exc_info=True, - ) - - value = { - "kind": "begin", - "title": title, - } - if message is not None: - value["message"] = message - if percentage is not None: - value["percentage"] = percentage - - self._endpoint.notify( - self.M_PROGRESS, - params={ - "token": token, - "value": value, - }, - ) - return token - - def _progress_report( - self, - token: str, - message: Optional[str] = None, - percentage: Optional[int] = None, - ) -> None: - value = { - "kind": "report", - } - if message: - value["message"] = message - if percentage: - value["percentage"] = percentage - - self._endpoint.notify( - self.M_PROGRESS, - params={ - "token": token, - "value": value, - }, - ) - - def _progress_end(self, token: str, message: Optional[str] = None) -> None: - value = { - "kind": "end", - } - if message: - value["message"] = message - - self._endpoint.notify( - self.M_PROGRESS, - params={ - "token": token, - "value": value, - }, - ) - - def log_message(self, message, msg_type=lsp.MessageType.Info): - self._endpoint.notify( - self.M_LOG_MESSAGE, params={"type": msg_type, "message": message} - ) - - def show_message(self, message, msg_type=lsp.MessageType.Info) -> None: - self._endpoint.notify( - self.M_SHOW_MESSAGE, params={"type": msg_type, "message": message} - ) - - def source_roots(self, document_path): - """Return the source roots for the given document.""" - files = ( - _utils.find_parents( - self._root_path, document_path, ["setup.py", "pyproject.toml"] - ) - or [] - ) - return list({os.path.dirname(project_file) for project_file in files}) or [ - self._root_path - ] - - def _create_document(self, doc_uri, source=None, version=None): - path = uris.to_fs_path(doc_uri) - return Document( - doc_uri, - self, - source=source, - version=version, - extra_sys_path=self.source_roots(path), - rope_project_builder=self._rope_project_builder, - ) - - def _create_notebook_document( - self, doc_uri, notebook_type, cells, version=None, metadata=None - ): - return Notebook( - doc_uri, - notebook_type, - self, - cells=cells, - version=version, - metadata=metadata, - ) - - def _create_cell_document( - self, doc_uri, notebook_uri, language_id, source=None, version=None - ): - # TODO: remove what is unnecessary here. - path = uris.to_fs_path(doc_uri) - return Cell( - doc_uri, - notebook_uri=notebook_uri, - language_id=language_id, - workspace=self, - source=source, - version=version, - extra_sys_path=self.source_roots(path), - rope_project_builder=self._rope_project_builder, - ) - - def close(self) -> None: - if self.__rope_autoimport: - self.__rope_autoimport.close() - - -class Document: - def __init__( - self, - uri, - workspace, - source=None, - version=None, - local=True, - extra_sys_path=None, - rope_project_builder=None, - ) -> None: - self.uri = uri - self.version = version - self.path = uris.to_fs_path(uri) - self.dot_path = _utils.path_to_dot_name(self.path) - self.filename = os.path.basename(self.path) - self.shared_data = {} - - self._config = workspace._config - self._workspace = workspace - self._local = local - self._source = source - self._extra_sys_path = extra_sys_path or [] - self._rope_project_builder = rope_project_builder - self._lock = RLock() - - def __str__(self): - return str(self.uri) - - def _rope_resource(self, rope_config): - from rope.base import libutils - - return libutils.path_to_resource( - self._rope_project_builder(rope_config), self.path - ) - - @property - @lock - def lines(self): - return self.source.splitlines(True) - - @property - @lock - def source(self): - if self._source is None: - with open(self.path, encoding="utf-8") as f: - return f.read() - return self._source - - def update_config(self, settings) -> None: - self._config.update((settings or {}).get("pylsp", {})) - - @lock - def apply_change(self, change): - """Apply a change to the document.""" - text = change["text"] - change_range = change.get("range") - - if not change_range: - # The whole file has changed - self._source = text - return - - start_line = change_range["start"]["line"] - start_col = change_range["start"]["character"] - end_line = change_range["end"]["line"] - end_col = change_range["end"]["character"] - - # Check for an edit occuring at the very end of the file - lines = self.lines - if start_line == len(lines): - self._source = self.source + text - return - - new = io.StringIO() - - # Iterate over the existing document until we hit the edit range, - # at which point we write the new text, then loop until we hit - # the end of the range and continue writing. - for i, line in enumerate(lines): - if i < start_line: - new.write(line) - continue - - if i > end_line: - new.write(line) - continue - - if i == start_line: - new.write(line[:start_col]) - new.write(text) - - if i == end_line: - new.write(line[end_col:]) - - self._source = new.getvalue() - - def offset_at_position(self, position): - """Return the byte-offset pointed at by the given position.""" - return position["character"] + len("".join(self.lines[: position["line"]])) - - def word_at_position(self, position): - """Get the word under the cursor returning the start and end positions.""" - lines = self.lines - if position["line"] >= len(lines): - return "" - - line = lines[position["line"]] - i = position["character"] - # Split word in two - start = line[:i] - end = line[i:] - - # Take end of start and start of end to find word - # These are guaranteed to match, even if they match the empty string - m_start = RE_START_WORD.findall(start) - m_end = RE_END_WORD.findall(end) - - return m_start[0] + m_end[-1] - - @lock - def jedi_names(self, all_scopes=False, definitions=True, references=False): - script = self.jedi_script() - return script.get_names( - all_scopes=all_scopes, definitions=definitions, references=references - ) - - @lock - def jedi_script(self, position=None, use_document_path=False): - extra_paths = [] - environment_path = None - env_vars = None - prioritize_extra_paths = False - - if self._config: - jedi_settings = self._config.plugin_settings( - "jedi", document_path=self.path - ) - jedi.settings.auto_import_modules = jedi_settings.get( - "auto_import_modules", DEFAULT_AUTO_IMPORT_MODULES - ) - environment_path = jedi_settings.get("environment") - # Jedi itself cannot deal with homedir-relative paths. - # On systems, where it is expected, expand the home directory. - if environment_path and os.name != "nt": - environment_path = os.path.expanduser(environment_path) - - extra_paths = jedi_settings.get("extra_paths") or [] - env_vars = jedi_settings.get("env_vars") - prioritize_extra_paths = jedi_settings.get("prioritize_extra_paths") - - # Drop PYTHONPATH from env_vars before creating the environment to - # ensure that Jedi can startup properly without module name collision. - if env_vars is None: - env_vars = os.environ.copy() - env_vars.pop("PYTHONPATH", None) - - environment = self.get_enviroment(environment_path, env_vars=env_vars) - sys_path = self.sys_path( - environment_path, env_vars, prioritize_extra_paths, extra_paths - ) - - project_path = self._workspace.root_path - - # Extend sys_path with document's path if requested - if use_document_path: - sys_path += [os.path.normpath(os.path.dirname(self.path))] - - kwargs = { - "code": self.source, - "path": self.path, - "environment": environment if environment_path else None, - "project": jedi.Project(path=project_path, sys_path=sys_path), - } - - if position: - # Deprecated by Jedi to use in Script() constructor - kwargs += _utils.position_to_jedi_linecolumn(self, position) - - return jedi.Script(**kwargs) - - def get_enviroment(self, environment_path=None, env_vars=None): - # TODO(gatesn): #339 - make better use of jedi environments, they seem pretty powerful - if environment_path is None: - environment = jedi.api.environment.get_cached_default_environment() - else: - if environment_path in self._workspace._environments: - environment = self._workspace._environments[environment_path] - else: - environment = jedi.api.environment.create_environment( - path=environment_path, safe=False, env_vars=env_vars - ) - self._workspace._environments[environment_path] = environment - - return environment - - def sys_path( - self, - environment_path=None, - env_vars=None, - prioritize_extra_paths=False, - extra_paths=[], - ): - # Copy our extra sys path - path = list(self._extra_sys_path) - environment = self.get_enviroment( - environment_path=environment_path, env_vars=env_vars - ) - path.extend(environment.get_sys_path()) - if prioritize_extra_paths: - path = extra_paths + path - else: - path = path + extra_paths - - return path - - -class Notebook: - """Represents a notebook.""" - - def __init__( - self, uri, notebook_type, workspace, cells=None, version=None, metadata=None - ) -> None: - self.uri = uri - self.notebook_type = notebook_type - self.workspace = workspace - self.version = version - self.cells = cells or [] - self.metadata = metadata or {} - self._lock = RLock() - - def __str__(self): - return "Notebook with URI '%s'" % str(self.uri) - - def add_cells(self, new_cells: list, start: int) -> None: - self.cells[start:start] = new_cells - - def remove_cells(self, start: int, delete_count: int) -> None: - del self.cells[start : start + delete_count] - - def cell_data(self): - """Extract current cell data. - - Returns a dict (ordered by cell position) where the key is the cell uri and the - value is a dict with line_start, line_end, and source attributes. - """ - cell_data = {} - offset = 0 - for cell in self.cells: - cell_uri = cell["document"] - cell_document = self.workspace.get_cell_document(cell_uri) - num_lines = cell_document.line_count - cell_data[cell_uri] = { - "line_start": offset, - "line_end": offset + num_lines - 1, - "source": cell_document.source, - } - offset += num_lines - return cell_data - - @lock - def jedi_names( - self, - up_to_cell_uri: Optional[str] = None, - all_scopes=False, - definitions=True, - references=False, - ): - """ - Get the names in the notebook up to a certain cell. - - Parameters - ---------- - up_to_cell_uri: str, optional - The cell uri to stop at. If None, all cells are considered. - """ - names = set() - for cell in self.cells: - cell_uri = cell["document"] - cell_document = self.workspace.get_cell_document(cell_uri) - names.update(cell_document.jedi_names(all_scopes, definitions, references)) - if cell_uri == up_to_cell_uri: - break - return {name.name for name in names} - - -class Cell(Document): - """ - Represents a cell in a notebook. - - Notes - ----- - We inherit from Document for now to get the same API. However, a cell document differs from text documents in that - they have a language id. - """ - - def __init__( - self, - uri, - notebook_uri, - language_id, - workspace, - source=None, - version=None, - local=True, - extra_sys_path=None, - rope_project_builder=None, - ) -> None: - super().__init__( - uri, workspace, source, version, local, extra_sys_path, rope_project_builder - ) - self.language_id = language_id - self.notebook_uri = notebook_uri - - @property - @lock - def line_count(self): - """ "Return the number of lines in the cell document.""" - return len(self.source.split("\n")) From 8b3be53ef0ae11277133320f00452b192c6e86d9 Mon Sep 17 00:00:00 2001 From: Hendrik Dumith Louzada Date: Fri, 7 Nov 2025 16:47:43 -0300 Subject: [PATCH 2/4] feat: update plugin to new api --- pylsp/plugins/_resolvers.py | 2 +- pylsp/plugins/flake8_lint.py | 5 ++-- pylsp/plugins/highlight.py | 5 ++-- pylsp/plugins/jedi_completion.py | 8 +++--- pylsp/plugins/mccabe_lint.py | 5 ++-- pylsp/plugins/pycodestyle_lint.py | 5 ++-- pylsp/plugins/pydocstyle_lint.py | 5 ++-- pylsp/plugins/pyflakes_lint.py | 7 +++--- pylsp/plugins/pylint_lint.py | 41 +++++++++++++++++-------------- pylsp/plugins/rope_completion.py | 7 +++--- pylsp/plugins/symbols.py | 2 +- 11 files changed, 53 insertions(+), 39 deletions(-) diff --git a/pylsp/plugins/_resolvers.py b/pylsp/plugins/_resolvers.py index dcfd06ab..63700490 100644 --- a/pylsp/plugins/_resolvers.py +++ b/pylsp/plugins/_resolvers.py @@ -7,7 +7,7 @@ from jedi.api.classes import Completion -from pylsp import lsp +from lsprotocol import types as lsp log = logging.getLogger(__name__) diff --git a/pylsp/plugins/flake8_lint.py b/pylsp/plugins/flake8_lint.py index 0ac91855..818106ad 100644 --- a/pylsp/plugins/flake8_lint.py +++ b/pylsp/plugins/flake8_lint.py @@ -12,7 +12,8 @@ from flake8.plugins.pyflakes import FLAKE8_PYFLAKES_CODES -from pylsp import hookimpl, lsp +from pylsp import hookimpl +from lsprotocol import types as lsp from pylsp.plugins.pyflakes_lint import PYFLAKES_ERROR_MESSAGES log = logging.getLogger(__name__) @@ -42,7 +43,7 @@ def pylsp_settings(): @hookimpl -def pylsp_lint(workspace, document): +def pylsp_lint(config, workspace, document, is_saved): # noqa: ARG001 (is_saved unused) with workspace.report_progress("lint: flake8"): config = workspace._config settings = config.plugin_settings("flake8", document_path=document.path) diff --git a/pylsp/plugins/highlight.py b/pylsp/plugins/highlight.py index c4c12406..3d3fbf33 100644 --- a/pylsp/plugins/highlight.py +++ b/pylsp/plugins/highlight.py @@ -3,13 +3,14 @@ import logging -from pylsp import _utils, hookimpl, lsp +from pylsp import _utils, hookimpl +from lsprotocol import types as lsp log = logging.getLogger(__name__) @hookimpl -def pylsp_document_highlight(document, position): +def pylsp_document_highlight(config, workspace, document, position): code_position = _utils.position_to_jedi_linecolumn(document, position) usages = document.jedi_script().get_references(**code_position) diff --git a/pylsp/plugins/jedi_completion.py b/pylsp/plugins/jedi_completion.py index 51c3589c..e422b8d3 100644 --- a/pylsp/plugins/jedi_completion.py +++ b/pylsp/plugins/jedi_completion.py @@ -6,7 +6,8 @@ import parso -from pylsp import _utils, hookimpl, lsp +from pylsp import _utils, hookimpl +from lsprotocol import types as lsp from pylsp.plugins._resolvers import LABEL_RESOLVER, SNIPPET_RESOLVER log = logging.getLogger(__name__) @@ -36,7 +37,7 @@ @hookimpl -def pylsp_completions(config, document, position): +def pylsp_completions(config, workspace, document, position, ignored_names=None): """Get formatted completions for current code position""" settings = config.plugin_settings("jedi_completion", document_path=document.path) resolve_eagerly = settings.get("eager", False) @@ -143,8 +144,9 @@ def pylsp_completions(config, document, position): @hookimpl def pylsp_completion_item_resolve( config, - completion_item, + workspace, document, + completion_item, ): """Resolve formatted completion for given non-resolved completion""" shared_data = document.shared_data["LAST_JEDI_COMPLETIONS"].get( diff --git a/pylsp/plugins/mccabe_lint.py b/pylsp/plugins/mccabe_lint.py index 0e2cba2e..53706cec 100644 --- a/pylsp/plugins/mccabe_lint.py +++ b/pylsp/plugins/mccabe_lint.py @@ -6,7 +6,8 @@ import mccabe -from pylsp import hookimpl, lsp +from pylsp import hookimpl +from lsprotocol import types as lsp log = logging.getLogger(__name__) @@ -15,7 +16,7 @@ @hookimpl -def pylsp_lint(config, workspace, document): +def pylsp_lint(config, workspace, document, is_saved): # noqa: ARG001 (is_saved unused) with workspace.report_progress("lint: mccabe"): threshold = config.plugin_settings("mccabe", document_path=document.path).get( THRESHOLD, DEFAULT_THRESHOLD diff --git a/pylsp/plugins/pycodestyle_lint.py b/pylsp/plugins/pycodestyle_lint.py index 7a514adf..fc67a5a9 100644 --- a/pylsp/plugins/pycodestyle_lint.py +++ b/pylsp/plugins/pycodestyle_lint.py @@ -5,7 +5,8 @@ import pycodestyle -from pylsp import hookimpl, lsp +from pylsp import hookimpl +from lsprotocol import types as lsp from pylsp._utils import get_eol_chars try: @@ -24,7 +25,7 @@ @hookimpl -def pylsp_lint(workspace, document): +def pylsp_lint(config, workspace, document, is_saved): # noqa: ARG001 (is_saved unused) with workspace.report_progress("lint: pycodestyle"): config = workspace._config settings = config.plugin_settings("pycodestyle", document_path=document.path) diff --git a/pylsp/plugins/pydocstyle_lint.py b/pylsp/plugins/pydocstyle_lint.py index a310ac84..11790737 100644 --- a/pylsp/plugins/pydocstyle_lint.py +++ b/pylsp/plugins/pydocstyle_lint.py @@ -9,7 +9,8 @@ import pydocstyle -from pylsp import hookimpl, lsp +from pylsp import hookimpl +from lsprotocol import types as lsp log = logging.getLogger(__name__) @@ -28,7 +29,7 @@ def pylsp_settings(): @hookimpl -def pylsp_lint(config, workspace, document): +def pylsp_lint(config, workspace, document, is_saved): # noqa: ARG001 (is_saved unused) with workspace.report_progress("lint: pydocstyle"): settings = config.plugin_settings("pydocstyle", document_path=document.path) log.debug("Got pydocstyle settings: %s", settings) diff --git a/pylsp/plugins/pyflakes_lint.py b/pylsp/plugins/pyflakes_lint.py index 8a04276c..f3e9cdda 100644 --- a/pylsp/plugins/pyflakes_lint.py +++ b/pylsp/plugins/pyflakes_lint.py @@ -4,7 +4,8 @@ from pyflakes import api as pyflakes_api from pyflakes import messages -from pylsp import hookimpl, lsp +from pylsp import hookimpl +from lsprotocol import types as lsp # Pyflakes messages that should be reported as Errors instead of Warns PYFLAKES_ERROR_MESSAGES = ( @@ -22,7 +23,7 @@ @hookimpl -def pylsp_lint(workspace, document): +def pylsp_lint(config, workspace, document, is_saved): # noqa: ARG001 (is_saved unused) with workspace.report_progress("lint: pyflakes"): reporter = PyflakesDiagnosticReport(document.lines) pyflakes_api.check( @@ -36,7 +37,7 @@ def __init__(self, lines) -> None: self.lines = lines self.diagnostics = [] - def unexpectedError(self, _filename, msg) -> None: # pragma: no cover + def unexpectedError(self, _filename, msg) -> None: err_range = { "start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 0}, diff --git a/pylsp/plugins/pylint_lint.py b/pylsp/plugins/pylint_lint.py index f3415c8a..54fbf0ce 100644 --- a/pylsp/plugins/pylint_lint.py +++ b/pylsp/plugins/pylint_lint.py @@ -12,7 +12,9 @@ import sys from subprocess import PIPE, Popen -from pylsp import hookimpl, lsp +from lsprotocol.types import DiagnosticSeverity, DiagnosticTag + +from pylsp import hookimpl try: import ujson as json @@ -74,7 +76,7 @@ def lint(cls, document, is_saved, flags=""): }, } 'message': msg, - 'severity': lsp.DiagnosticSeverity.*, + 'severity': DiagnosticSeverity.*, } """ if not is_saved: @@ -160,17 +162,20 @@ def lint(cls, document, is_saved, flags=""): } if diag["type"] == "convention": - severity = lsp.DiagnosticSeverity.Information + severity = DiagnosticSeverity.Information elif diag["type"] == "information": - severity = lsp.DiagnosticSeverity.Information + severity = DiagnosticSeverity.Information elif diag["type"] == "error": - severity = lsp.DiagnosticSeverity.Error + severity = DiagnosticSeverity.Error elif diag["type"] == "fatal": - severity = lsp.DiagnosticSeverity.Error + severity = DiagnosticSeverity.Error elif diag["type"] == "refactor": - severity = lsp.DiagnosticSeverity.Hint + severity = DiagnosticSeverity.Hint elif diag["type"] == "warning": - severity = lsp.DiagnosticSeverity.Warning + severity = DiagnosticSeverity.Warning + else: + log.warning("Unknown pylint diagnostic type '%s'", diag["type"]) + severity = DiagnosticSeverity.Error code = diag["message-id"] @@ -183,9 +188,9 @@ def lint(cls, document, is_saved, flags=""): } if code in UNNECESSITY_CODES: - diagnostic["tags"] = [lsp.DiagnosticTag.Unnecessary] + diagnostic["tags"] = [DiagnosticTag.Unnecessary] if code in DEPRECATION_CODES: - diagnostic["tags"] = [lsp.DiagnosticTag.Deprecated] + diagnostic["tags"] = [DiagnosticTag.Deprecated] diagnostics.append(diagnostic) cls.last_diags[document.path] = diagnostics @@ -327,12 +332,12 @@ def _parse_pylint_stdio_result(document, stdout): line = int(line) - 1 character = int(character) severity_map = { - "C": lsp.DiagnosticSeverity.Information, - "E": lsp.DiagnosticSeverity.Error, - "F": lsp.DiagnosticSeverity.Error, - "I": lsp.DiagnosticSeverity.Information, - "R": lsp.DiagnosticSeverity.Hint, - "W": lsp.DiagnosticSeverity.Warning, + "C": DiagnosticSeverity.Information, + "E": DiagnosticSeverity.Error, + "F": DiagnosticSeverity.Error, + "I": DiagnosticSeverity.Information, + "R": DiagnosticSeverity.Hint, + "W": DiagnosticSeverity.Warning, } severity = severity_map[code[0]] diagnostic = { @@ -351,9 +356,9 @@ def _parse_pylint_stdio_result(document, stdout): "severity": severity, } if code in UNNECESSITY_CODES: - diagnostic["tags"] = [lsp.DiagnosticTag.Unnecessary] + diagnostic["tags"] = [DiagnosticTag.Unnecessary] if code in DEPRECATION_CODES: - diagnostic["tags"] = [lsp.DiagnosticTag.Deprecated] + diagnostic["tags"] = [DiagnosticTag.Deprecated] diagnostics.append(diagnostic) return diagnostics diff --git a/pylsp/plugins/rope_completion.py b/pylsp/plugins/rope_completion.py index dc94ddea..58df4d4d 100644 --- a/pylsp/plugins/rope_completion.py +++ b/pylsp/plugins/rope_completion.py @@ -5,7 +5,8 @@ from rope.contrib.codeassist import code_assist, sorted_proposals -from pylsp import _utils, hookimpl, lsp +from pylsp import _utils, hookimpl +from lsprotocol import types as lsp log = logging.getLogger(__name__) @@ -28,7 +29,7 @@ def _resolve_completion(completion, data, markup_kind): @hookimpl -def pylsp_completions(config, workspace, document, position): +def pylsp_completions(config, workspace, document, position, ignored_names=None): settings = config.plugin_settings("rope_completion", document_path=document.path) resolve_eagerly = settings.get("eager", False) @@ -90,7 +91,7 @@ def pylsp_completions(config, workspace, document, position): @hookimpl -def pylsp_completion_item_resolve(config, completion_item, document): +def pylsp_completion_item_resolve(config, workspace, document, completion_item): """Resolve formatted completion for given non-resolved completion""" shared_data = document.shared_data["LAST_ROPE_COMPLETIONS"].get( completion_item["label"] diff --git a/pylsp/plugins/symbols.py b/pylsp/plugins/symbols.py index 3a7beb07..b5caad35 100644 --- a/pylsp/plugins/symbols.py +++ b/pylsp/plugins/symbols.py @@ -6,7 +6,7 @@ from pathlib import Path from pylsp import hookimpl -from pylsp.lsp import SymbolKind +from lsprotocol.types import SymbolKind log = logging.getLogger(__name__) From 60cbf53903285b7c279a1078f1b67fe762729730 Mon Sep 17 00:00:00 2001 From: Hendrik Dumith Louzada Date: Fri, 7 Nov 2025 16:48:00 -0300 Subject: [PATCH 3/4] feat: add embedded plugins config source --- pylsp/plugins/config.py | 79 +++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 1 + 2 files changed, 80 insertions(+) create mode 100644 pylsp/plugins/config.py diff --git a/pylsp/plugins/config.py b/pylsp/plugins/config.py new file mode 100644 index 00000000..428eff53 --- /dev/null +++ b/pylsp/plugins/config.py @@ -0,0 +1,79 @@ +# Copyright 2025- Python Language Server Contributors. + +import os +import sys + +import pycodestyle + +from pylsp import hookimpl +from pylsp.config.source import ConfigSource + + +class Flake8Config(ConfigSource): + """Parse flake8 configurations.""" + + CONFIG_KEY = "flake8" + PROJECT_CONFIGS = [".flake8", "setup.cfg", "tox.ini"] + USER_CONFIGS = ( + [os.path.expanduser("~\\.flake8")] + if sys.platform == "win32" else [os.path.join(ConfigSource.XDG_CONFIG_HOME, "flake8")] + ) + + OPTIONS = [ + # mccabe + ("max-complexity", "plugins.mccabe.threshold", int), + # pycodestyle + ("exclude", "plugins.pycodestyle.exclude", list), + ("filename", "plugins.pycodestyle.filename", list), + ("hang-closing", "plugins.pycodestyle.hangClosing", bool), + ("ignore", "plugins.pycodestyle.ignore", list), + ("max-line-length", "plugins.pycodestyle.maxLineLength", int), + ("indent-size", "plugins.pycodestyle.indentSize", int), + ("select", "plugins.pycodestyle.select", list), + # flake8 + ("exclude", "plugins.flake8.exclude", list), + ("extend-ignore", "plugins.flake8.extendIgnore", list), + ("extend-select", "plugins.flake8.extendSelect", list), + ("filename", "plugins.flake8.filename", list), + ("hang-closing", "plugins.flake8.hangClosing", bool), + ("ignore", "plugins.flake8.ignore", list), + ("max-complexity", "plugins.flake8.maxComplexity", int), + ("max-line-length", "plugins.flake8.maxLineLength", int), + ("indent-size", "plugins.flake8.indentSize", int), + ("select", "plugins.flake8.select", list), + ("per-file-ignores", "plugins.flake8.perFileIgnores", list), + ] + + @classmethod + def _parse_list_opt(cls, string): + if string.startswith("\n"): + return [s.strip().rstrip(",") for s in string.split("\n") if s.strip()] + return [s.strip() for s in string.split(",") if s.strip()] + + +class PyCodeStyleConfig(ConfigSource): + CONFIG_KEY = "pycodestyle" + USER_CONFIGS = [pycodestyle.USER_CONFIG] if pycodestyle.USER_CONFIG else [] + PROJECT_CONFIGS = ["pycodestyle.cfg", "setup.cfg", "tox.ini"] + + OPTIONS = [ + ("exclude", "plugins.pycodestyle.exclude", list), + ("filename", "plugins.pycodestyle.filename", list), + ("hang-closing", "plugins.pycodestyle.hangClosing", bool), + ("ignore", "plugins.pycodestyle.ignore", list), + ("max-line-length", "plugins.pycodestyle.maxLineLength", int), + ("indent-size", "plugins.pycodestyle.indentSize", int), + ("select", "plugins.pycodestyle.select", list), + ("aggressive", "plugins.pycodestyle.aggressive", int), + ] + + +# ---- pylsp_settings hook implementation (new architecture) ---- +@hookimpl +def pylsp_settings(): # noqa: ARG001 (config not used yet) + """Provide configuration source classes to the server. + + The server will instantiate each returned class with the workspace root. + Returning classes (not instances) matches the new ServerConfig collection flow. + """ + return [Flake8Config, PyCodeStyleConfig] diff --git a/pyproject.toml b/pyproject.toml index 0be4035c..b6662aa5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -85,6 +85,7 @@ pylint = "pylsp.plugins.pylint_lint" rope_completion = "pylsp.plugins.rope_completion" rope_autoimport = "pylsp.plugins.rope_autoimport" yapf = "pylsp.plugins.yapf_format" +config_sources = "pylsp.plugins.config" [project.scripts] pylsp = "pylsp.__main__:main" From 173518c6bb64c62b48eec06de27c1327769b61d6 Mon Sep 17 00:00:00 2001 From: Hendrik Dumith Louzada Date: Fri, 7 Nov 2025 16:54:14 -0300 Subject: [PATCH 4/4] feat: update dependencies for pygls --- pyproject.toml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b6662aa5..3feb90e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,9 @@ dependencies = [ "pluggy>=1.0.0", "python-lsp-jsonrpc>=1.1.0,<2.0.0", "ujson>=3.0.0", - "black" + "black", + "lsprotocol>=2025.0.0,<2026.0.0", + "pygls>=1.3.1,<2.0.0", ] dynamic = ["version"]