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 @@ [![Anaconda](https://anaconda.org/conda-forge/python-lsp-ruff/badges/version.svg)](https://anaconda.org/conda-forge/python-lsp-ruff) [![Python](https://github.com/python-lsp/python-lsp-ruff/actions/workflows/python.yml/badge.svg)](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):