diff --git a/.gitignore b/.gitignore
index 5a9e939..b59b74a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,5 @@ __pycache__/
*.swp
tags
/build/
+
+.spyproject/
diff --git a/README.md b/README.md
index 0fc961c..249b0a2 100644
--- a/README.md
+++ b/README.md
@@ -4,9 +4,12 @@
[](https://anaconda.org/conda-forge/python-lsp-ruff)
[](https://github.com/python-lsp/python-lsp-ruff/actions/workflows/python.yml)
-`python-lsp-ruff` is a plugin for `python-lsp-server` that adds linting, code action and formatting capabilities that are provided by [ruff](https://github.com/charliermarsh/ruff),
+`python-lsp-ruff` is a plugin for `python-lsp-server` that adds **linting**, **code actions** and **formatting** capabilities that are provided by [ruff](https://github.com/charliermarsh/ruff),
an extremely fast Python linter and formatter written in Rust.
+Note that `ruff>0.4.5` ships with a built-in LSP server (and `ruff-lsp` before that), which allows linting, formatting and code actions.
+In contrast, this implementation adds `ruff` as a plugin for `pylsp` in addition to `pylsp`'s other functionalities (go-to support, ...).
+
## Install
In the same `virtualenv` as `python-lsp-server`:
@@ -40,6 +43,9 @@ file is present in the project, `python-lsp-ruff` will ignore specific options (
The plugin follows [python-lsp-server's configuration](https://github.com/python-lsp/python-lsp-server/#configuration).
This example configuration using for `neovim` shows the possible options:
+
+Lua
+
```lua
pylsp = {
plugins = {
@@ -53,6 +59,7 @@ pylsp = {
format = { "I" }, -- Rules that are marked as fixable by ruff that should be fixed when running textDocument/formatting
severities = { ["D212"] = "I" }, -- Optional table of rules where a custom severity is desired
unsafeFixes = false, -- Whether or not to offer unsafe fixes as code actions. Ignored with the "Fix All" action
+ unfixable = { "F401" }, -- Rules that are excluded when checking the code actions (including the "Fix All" action)
-- Rules that are ignored when a pyproject.toml or ruff.toml is present:
lineLength = 88, -- Line length to pass to ruff checking and formatting
@@ -66,6 +73,43 @@ pylsp = {
}
}
```
+
+
+
+JSON
+
+```
+{
+ "pylsp": {
+ "plugins": {
+ "ruff": {
+ "enabled": true,
+ "formatEnabled": true,
+ "executable": "",
+ "config": "",
+ "extendSelect": [ "I" ],
+ "extendIgnore": [ "C90"],
+ "format": [ "I" ],
+ "severities": {
+ "D212": "I"
+ },
+ "unsafeFixes": false,
+ "unfixable": [ "F401" ],
+ "lineLength": 88,
+ "exclude": ["__about__.py"],
+ "select": ["F"],
+ "ignore": ["D210"],
+ "perFileIgnores": {
+ "__init__.py": "CPY001"
+ },
+ "preview": false,
+ "targetVersion": "py310"
+ }
+ }
+ }
+}
+```
+
For more information on the configuration visit [Ruff's homepage](https://beta.ruff.rs/docs/configuration/).
@@ -101,7 +145,7 @@ The `Fix all` code action *only* consideres safe fixes.
The log level can be set via the `cmd` option of `pylsp`:
```lua
-lspconfig.pylsp.setup {
+vim.lsp.config("pylsp", {
cmd = {"pylsp", "-vvv", "--log-file", "/tmp/lsp.log"},
settings = {
pylsp = {
@@ -112,5 +156,5 @@ lspconfig.pylsp.setup {
}
}
}
-}
+})
```
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..635b638
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,17 @@
+# Security Policy
+
+
+## Supported Versions
+
+We normally support only the most recently released version with bug fixes, security updates and compatibility improvements.
+
+
+## Reporting a Vulnerability
+
+If you believe you've discovered a security vulnerability in this project, please open a new security advisory with [our GitHub repo's private vulnerability reporting](https://github.com/python-lsp/python-lsp-ruff/security/advisories/new).
+Please be sure to carefully document the vulnerability, including a summary, describing the impacts, identifying the line(s) of code affected, stating the conditions under which it is exploitable and including a minimal reproducible test case.
+Further information and advice or patches on how to mitigate it is always welcome.
+You can usually expect to hear back within 1 week, at which point we'll inform you of our evaluation of the vulnerability and what steps we plan to take, and will reach out if we need further clarification from you.
+We'll discuss and update the advisory thread, and are happy to update you on its status should you further inquire.
+While this is a volunteer project and we don't have financial compensation to offer, we can certainly publicly thank and credit you for your help if you would like.
+Thanks!
diff --git a/pylsp_ruff/plugin.py b/pylsp_ruff/plugin.py
index 833f16b..2c3f3d9 100644
--- a/pylsp_ruff/plugin.py
+++ b/pylsp_ruff/plugin.py
@@ -1,12 +1,22 @@
import enum
+import importlib.util
import json
import logging
import re
+import shutil
import sys
+from functools import lru_cache
from pathlib import PurePath
from subprocess import PIPE, Popen
from typing import Dict, Generator, List, Optional
+if sys.platform == "win32":
+ from subprocess import CREATE_NO_WINDOW
+else:
+ # CREATE_NO_WINDOW flag only available on Windows.
+ # Set constant as default `Popen` `creationflag` kwarg value (`0`)
+ CREATE_NO_WINDOW = 0
+
if sys.version_info >= (3, 11):
import tomllib
else:
@@ -128,34 +138,37 @@ def pylsp_format_document(workspace: Workspace, document: Document) -> Generator
if not settings.format_enabled:
return
- new_text = run_ruff_format(
- settings=settings, document_path=document.path, document_source=source
- )
-
- if settings.format:
- # A second pass through the document with `ruff check` and only the rules
- # enabled via the format config property. This allows for things like
- # specifying `format = ["I"]` to get import sorting as part of formatting.
- new_text = run_ruff(
- settings=PluginSettings(
- ignore=["ALL"], select=settings.format, executable=settings.executable
- ),
- document_path=document.path,
- document_source=new_text,
- fix=True,
+ with workspace.report_progress("format: ruff"):
+ new_text = run_ruff_format(
+ settings=settings, document_path=document.path, document_source=source
)
- # Avoid applying empty text edit
- if not new_text or new_text == source:
- return
+ if settings.format:
+ # A second pass through the document with `ruff check` and only the rules
+ # enabled via the format config property. This allows for things like
+ # specifying `format = ["I"]` to get import sorting as part of formatting.
+ new_text = run_ruff(
+ settings=PluginSettings(
+ ignore=["ALL"],
+ select=settings.format,
+ executable=settings.executable,
+ ),
+ document_path=document.path,
+ document_source=new_text,
+ fix=True,
+ )
- range = Range(
- start=Position(line=0, character=0),
- end=Position(line=len(document.lines), character=0),
- )
- text_edit = TextEdit(range=range, new_text=new_text)
+ # Avoid applying empty text edit
+ if not new_text or new_text == source:
+ return
+
+ range = Range(
+ start=Position(line=0, character=0),
+ end=Position(line=len(document.lines), character=0),
+ )
+ text_edit = TextEdit(range=range, new_text=new_text)
- outcome.force_result(converter.unstructure([text_edit]))
+ outcome.force_result(converter.unstructure([text_edit]))
@hookimpl
@@ -174,10 +187,11 @@ def pylsp_lint(workspace: Workspace, document: Document) -> List[Dict]:
List of dicts containing the diagnostics.
"""
- settings = load_settings(workspace, document.path)
- checks = run_ruff_check(document=document, settings=settings)
- diagnostics = [create_diagnostic(check=c, settings=settings) for c in checks]
- return converter.unstructure(diagnostics)
+ with workspace.report_progress("lint: ruff"):
+ settings = load_settings(workspace, document.path)
+ checks = run_ruff_check(document=document, settings=settings)
+ diagnostics = [create_diagnostic(check=c, settings=settings) for c in checks]
+ return converter.unstructure(diagnostics)
def create_diagnostic(check: RuffCheck, settings: PluginSettings) -> Diagnostic:
@@ -481,6 +495,37 @@ def run_ruff_format(
)
+@lru_cache
+def find_executable(executable) -> List[str]:
+ cmd = None
+ # use the explicit executable configuration
+ if executable is not None:
+ exe_path = shutil.which(executable)
+ if exe_path is not None:
+ cmd = [exe_path]
+ else:
+ log.error(f"Configured ruff executable not found: {executable!r}")
+
+ # try the python module
+ if cmd is None:
+ if importlib.util.find_spec("ruff") is not None:
+ cmd = [sys.executable.replace("pythonw", "python"), "-m", "ruff"]
+
+ # try system's ruff executable
+ if cmd is None:
+ system_exe = shutil.which("ruff")
+ if system_exe is not None:
+ cmd = [system_exe]
+
+ if cmd is None:
+ log.error(
+ "No suitable ruff invocation could be found (executable, python module)."
+ )
+ cmd = []
+
+ return cmd
+
+
def run_ruff(
settings: PluginSettings,
document_path: str,
@@ -516,27 +561,14 @@ def run_ruff(
arguments = subcommand.build_args(document_path, settings, fix, extra_arguments)
- p = None
- if executable is not None:
- log.debug(f"Calling {executable} with args: {arguments} on '{document_path}'")
- try:
- cmd = [executable, str(subcommand)]
- cmd.extend(arguments)
- p = Popen(cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE)
- except Exception:
- log.error(f"Can't execute ruff with given executable '{executable}'.")
- if p is None:
- log.debug(
- f"Calling ruff via '{sys.executable} -m ruff'"
- f" with args: {arguments} on '{document_path}'"
- )
- cmd = [sys.executable, "-m", "ruff", str(subcommand)]
- cmd.extend(arguments)
- p = Popen(cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE)
- (stdout, stderr) = p.communicate(document_source.encode())
+ cmd = [*find_executable(executable), str(subcommand), *arguments]
+
+ log.debug(f"Calling {cmd} on '{document_path}'")
+ p = Popen(cmd, stdin=PIPE, stdout=PIPE, creationflags=CREATE_NO_WINDOW)
+ (stdout, _) = p.communicate(document_source.encode())
if p.returncode != 0:
- log.error(f"Error running ruff: {stderr.decode()}")
+ log.error(f"Ruff returned {p.returncode} != 0")
return stdout.decode()
@@ -596,6 +628,9 @@ def build_check_arguments(
if settings.unsafe_fixes:
args.append("--unsafe-fixes")
+ if settings.unfixable:
+ args.append(f"--unfixable={','.join(settings.unfixable)}")
+
if settings.exclude:
args.append(f"--exclude={','.join(settings.exclude)}")
@@ -698,8 +733,8 @@ def load_settings(workspace: Workspace, document_path: str) -> PluginSettings:
"""
config = workspace._config
- _plugin_settings = config.plugin_settings("ruff", document_path=document_path)
- plugin_settings = converter.structure(_plugin_settings, PluginSettings)
+ plugin_settings = config.plugin_settings("ruff", document_path=document_path)
+ plugin_settings = converter.structure(plugin_settings, PluginSettings)
pyproject_file = find_parents(
workspace.root_path, document_path, ["pyproject.toml"]
@@ -732,6 +767,7 @@ def load_settings(workspace: Workspace, document_path: str) -> PluginSettings:
extend_select=plugin_settings.extend_select,
format=plugin_settings.format,
severities=plugin_settings.severities,
+ unfixable=plugin_settings.unfixable,
)
return plugin_settings
diff --git a/pylsp_ruff/settings.py b/pylsp_ruff/settings.py
index 27af16f..c274dac 100644
--- a/pylsp_ruff/settings.py
+++ b/pylsp_ruff/settings.py
@@ -27,6 +27,8 @@ class PluginSettings:
preview: bool = False
unsafe_fixes: bool = False
+ unfixable: Optional[List[str]] = None
+
severities: Optional[Dict[str, str]] = None
target_version: Optional[str] = None
diff --git a/pyproject.toml b/pyproject.toml
index 88730ca..85883b0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -7,7 +7,7 @@ name = "python-lsp-ruff"
authors = [
{name = "Julian Hossbach", email = "julian.hossbach@gmx.de"}
]
-version = "2.2.2"
+version = "2.3.0"
description = "Ruff linting plugin for pylsp"
readme = "README.md"
requires-python = ">=3.8"
diff --git a/tests/test_code_actions.py b/tests/test_code_actions.py
index 809198e..9d97d58 100644
--- a/tests/test_code_actions.py
+++ b/tests/test_code_actions.py
@@ -50,6 +50,14 @@ def f():
"Ruff: Fix All (safe fixes)",
]
+codeactions_unfixable = [
+ "Ruff (F401): Remove unused import: `os`",
+ "Ruff (F401): Disable for this line",
+ # "Ruff (F841): Remove assignment to unused variable `a` (unsafe)",
+ "Ruff (F841): Disable for this line",
+ "Ruff: Fix All (safe fixes)",
+]
+
codeactions_import = [
"Ruff: Organize imports",
"Ruff: Fix All (safe fixes)",
@@ -85,6 +93,24 @@ def test_ruff_code_actions(workspace):
assert sorted(codeactions) == sorted(action_titles)
+def test_ruff_code_actions_unfixable(workspace):
+ _, doc = temp_document(codeaction_str, workspace)
+
+ workspace._config.update(
+ {"plugins": {"ruff": {"select": ["F"], "unfixable": ["F841"]}}}
+ )
+ diags = ruff_lint.pylsp_lint(workspace, doc)
+ range_ = cattrs.unstructure(
+ Range(start=Position(line=0, character=0), end=Position(line=0, character=0))
+ )
+ actions = ruff_lint.pylsp_code_actions(
+ workspace._config, workspace, doc, range=range_, context={"diagnostics": diags}
+ )
+ actions = converter.structure(actions, List[CodeAction])
+ action_titles = list(map(lambda action: action.title, actions))
+ assert sorted(codeactions_unfixable) == sorted(action_titles)
+
+
def test_import_action(workspace):
workspace._config.update(
{
diff --git a/tests/test_ruff_lint.py b/tests/test_ruff_lint.py
index 2933d26..fa915fc 100644
--- a/tests/test_ruff_lint.py
+++ b/tests/test_ruff_lint.py
@@ -2,6 +2,7 @@
# Copyright 2021- Python Language Server Contributors.
import os
+import stat
import sys
import tempfile
from unittest.mock import Mock, patch
@@ -100,17 +101,24 @@ def test_ruff_config_param(workspace):
def test_ruff_executable_param(workspace):
with patch("pylsp_ruff.plugin.Popen") as popen_mock:
- mock_instance = popen_mock.return_value
- mock_instance.communicate.return_value = [bytes(), bytes()]
+ with tempfile.NamedTemporaryFile() as ruff_exe:
+ mock_instance = popen_mock.return_value
+ mock_instance.communicate.return_value = [bytes(), bytes()]
- ruff_executable = "/tmp/ruff"
- workspace._config.update({"plugins": {"ruff": {"executable": ruff_executable}}})
+ ruff_executable = ruff_exe.name
+ # chmod +x the file
+ st = os.stat(ruff_executable)
+ os.chmod(ruff_executable, st.st_mode | stat.S_IEXEC)
- _name, doc = temp_document(DOC, workspace)
- ruff_lint.pylsp_lint(workspace, doc)
+ workspace._config.update(
+ {"plugins": {"ruff": {"executable": ruff_executable}}}
+ )
- (call_args,) = popen_mock.call_args[0]
- assert ruff_executable in call_args
+ _name, doc = temp_document(DOC, workspace)
+ ruff_lint.pylsp_lint(workspace, doc)
+
+ (call_args,) = popen_mock.call_args[0]
+ assert ruff_executable in call_args
def get_ruff_settings(workspace, doc, config_str):