Custom pylsp plugin

On creating custom pylsp plugins.

  ·   6 min read

In the previous post I mentioned python-lsp-server plugin system. This feature is one of the reasons why I like pylsp and use it together with much faster Ruff language server. Plugins make pylsp very flexible and easily customisable. It comes with a bunch of useful built-in plugins, some disabled by default. There are also very handy third-party plugins like pylsp-mypy. And the greatest part – I can easily create my own plugins.

pylsp plugin system relies on Pluggy library used by pytest. It introduces hook functions that are basically predefined methods of the host program (pylsp in our case). The plugin in this case is a package that defines implementations for hooks specified by the host.

I am going to explain this in detail with some toy examples. For real life examples feel free to check out my project Starkiller.

Implementing plugin logic #

Here you can see the full list of hooks specified by pylsp. No docstrings, but it is usually pretty obvious what they do.

To create some hook implementation you need to write a method with the same name and arguments:

from pylsp import hookimpl

@hookimpl
def pylsp_code_actions(
    config: Config,
    workspace: Workspace,
    document: Document,
    range: dict,
    context: dict,
) -> list[dict]:
    ...

There is a problem determining the expected return type though. It kind of makes sense that we need to return some JSON serializable objects defined by LSP specification. To make things simple we can just use Microsoft’s lsprotocol package that implements all necessary structures.

Let’s implement a simple Code Action that deletes the line under cursor. Code Actions are basically commands that the language server can execute on source code selected in your IDE. See the full specification here.

from lsprotocol.converters import get_converter
from lsprotocol.types import (
    CodeAction,
    CodeActionKind,
    Position,
    Range,
    TextEdit,
    WorkspaceEdit,
)
from pylsp import hookimpl
from pylsp.workspace import Document, Workspace

# This object is used to structure and unstructure LSP entities
converter = get_converter()


@hookimpl
def pylsp_code_actions(
    # These are pylsp entities
    config: Config,
    workspace: Workspace,
    document: Document,
    # These are raw LSP dicts
    range: dict,
    context: dict,
) -> list[dict]:
    # Convert selected code coordinates into a structured object and get first line range
    active_range = converter.structure(range, Range)
    line = document.lines[active_range.start.line].rstrip("\r\n")
    line_range = Range(
        start=Position(line=active_range.start.line, character=0),
        end=Position(line=active_range.start.line, character=len(line)),
    )

    # Prepare text edit
    line_range.end.line += 1
    line_range.end.character = 0
    text_edit = TextEdit(range=line_range, new_text="")

    # Prepare workspace edit
    workspace_edit = WorkspaceEdit(changes={document.uri: [text_edit]})

    # Prepare code action
    code_action = CodeAction(
        title="Delete line",
        kind=CodeActionKind.QuickFix,
        edit=workspace_edit,
    )
    
    return converter.unstructure([code_action])

Real life tasks will require a lot more lines of code invoking static code analysis and refactoring. At some point you may want to refactor not only the lines under the cursor, but the whole current document or even the whole project. You might need to be aware of your virtual environments: available Python versions and installed packages.

Here are some libraries that you might find helpful for static analysis and code refactoring:

  • ast, a built-in Python AST implementation, very useful for fast linting.
  • Parso, a CST implementation, which will help with complex source code edits.
  • Jedi and Rope, powerful refactoring libraries.

Logging #

You’ll probably want to see tracebacks and log messages from pylsp and our plugin. To do that we need to edit pylsp call in your preferred LSP client, be it Neovim or some IDE. Just make sure it is called with -vv --log-file /tmp/pylsp.log. E.g. for Neovim with lspconfig configuration would look like this:

{
    "neovim/nvim-lspconfig",
    config = function()
        local lspconfig = require("lspconfig")
        
        lspconfig.pylsp.setup {
            cmd = {"pylsp", "-vv", "--log-file", "/tmp/pylsp.log"},
            settings = {
                -- doesn't matter right now
            }
        }
    end
}

Now you will see error and pylsp log messages in /tmp/pylsp.log file.

If you want to add custom log messages for your plugin, it can be easily done with this code:

import logging

log = logging.getLogger(__name__)
log.debug("Initializing custom pylsp plugin")

Plugin entry point #

We need to configure an entry point for Pluggy to recognise our custom plugin. The modern way to set this and other package attributes is the pyproject.toml file.

Assume we have the following project structure:

pylsp-plugin-project/
├── src
│   ├── __init__.py
│   └── plugin.py 
└── pyproject.toml

Where plugin.py will contain hook implementations for our plugin.

Here is what we need to have in our pyproject.toml than:

[project.entry-points.pylsp]
our_plugin = "src.plugin"

Note the our_plugin word – this is basically the name of our plugin as it will be introduced to pylsp. We’ll use this word to enable and configure the plugin in pylsp settings.

Enabling the plugin in pylsp #

To enable our plugin in pylsp we need to build it as a package and install it into the same virtual environment where we have pylsp installed.

First part could be achieved with various tools for building Python packages. This is way out of this post scope, so we won’t go deep into it. You probably should just stick with Poetry or uv. These two are modern tools for dependency and environment management, and the latter is also blazing fast. Both provide build command to build your project into a package. See docs for details.

With this being done you’ll have a wheel file – the plugin’s packaged distribution. It will probably be located in dist/ directory of your project. Now we need to install it.

As I already mentioned in the previous post, I prefer pipx utility to install Python tools. It keeps the package in a separate virtual environment and also can inject additional dependencies into it. If you have pylsp installed via pipx, you’ll need to inject your plugin into pylsp environment:

pipx inject python-lsp-server ./dist/pylsp-plugin-project-<VERSION>-py3-none-any.whl 

With this pylsp will find the plugin in its environment and will recognise hook implementations inside it.

Finally, we need to enable the plugin in pylsp configuration. With Neovim and lspconfig:

{
    "neovim/nvim-lspconfig",
    config = function()
        local lspconfig = require("lspconfig")
        
        lspconfig.pylsp.setup {
            cmd = {"pylsp", "-vv", "--log-file", "/tmp/pylsp.log"},
            settings = {
                our_plugin = {enabled = true}
            }
        }
    end
}

As you can see, we use here the name of our package entry point from the previous section.

Now try to run your code editor and request available Code Actions in any Python script. In Neovim it can be done with vim.lsp.buf.code_action() command, and in an IDE with LSP support Code Actions will probably by listed in right mouse button menu (e.g. in PyCharm with LSP4IJ plugin).

If there are any problems, check out the log. If you implemented and enabled the plugin correctly, you’ll see messages like this:

INFO - pylsp.config.config - Loaded pylsp plugin our_plugin ...

Conclusion #

Creating custom pylsp plugins isn’t something every developer needs to do. Building your own tools is often way less productive than using well-maintained existing ones. However, from a static analysis perspective, it’s an interesting and rewarding exercise. It offers insight into how language servers work under the hood and opens the door to highly tailored editor features that can fit specific workflows or experimental ideas.