diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index d42c02d..06a37bb 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -10,16 +10,16 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.7, 3.8, 3.9, "3.10"] + python-version: [3.8, 3.9, "3.10", 3.11, 3.12] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: python -m pip install -e .[dev] - name: pre-commit checks - uses: pre-commit/action@v2.0.2 + uses: pre-commit/action@v3.0.0 - name: Tests run: pytest -v tests/ diff --git a/README.md b/README.md index 71f02ea..8a560ea 100644 --- a/README.md +++ b/README.md @@ -28,53 +28,43 @@ pip install "ruff<0.1.0" "python-lsp-ruff==1.5.3" This plugin will disable `pycodestyle`, `pyflakes`, `mccabe` and `pyls_isort` by default, unless they are explicitly enabled in the client configuration. When enabled, all linting diagnostics will be provided by `ruff`. -Sorting of the imports through `ruff` when formatting is enabled by default. -The list of code fixes can be changed via the `pylsp.plugins.ruff.format` option. Any codes given in the `format` option will only be marked as `fixable` for ruff during the formatting operation, the user has to make sure that these codes are also in the list of codes that ruff checks! -This example configuration for `neovim` shows how to always sort imports when running `textDocument/formatting`: - -```lua -lspconfig.pylsp.setup { - settings = { - pylsp = { - plugins = { - ruff = { - enabled = true, - extendSelect = { "I" }, - }, - } - } - } -} -``` ## Configuration Configuration options can be passed to the python-language-server. If a `pyproject.toml` -file is present in the project, `python-lsp-ruff` will use these configuration options. -Note that any configuration options (except for `extendIgnore` and `extendSelect`, see -[this issue](https://github.com/python-lsp/python-lsp-ruff/issues/19)) passed to ruff via -`pylsp` are ignored if the project has a `pyproject.toml`. - -The plugin follows [python-lsp-server's -configuration](https://github.com/python-lsp/python-lsp-server/#configuration). These are -the valid configuration keys: - - - `pylsp.plugins.ruff.enabled`: boolean to enable/disable the plugin. `true` by default. - - `pylsp.plugins.ruff.config`: Path to optional `pyproject.toml` file. - - `pylsp.plugins.ruff.exclude`: Exclude files from being checked by `ruff`. - - `pylsp.plugins.ruff.executable`: Path to the `ruff` executable. Uses `os.executable -m "ruff"` by default. - - `pylsp.plugins.ruff.ignore`: Error codes to ignore. - - `pylsp.plugins.ruff.extendIgnore`: Same as ignore, but append to existing ignores. - - `pylsp.plugins.ruff.lineLength`: Set the line-length for length checks. - - `pylsp.plugins.ruff.perFileIgnores`: File-specific error codes to be ignored. - - `pylsp.plugins.ruff.select`: List of error codes to enable. - - `pylsp.plugins.ruff.extendSelect`: Same as select, but append to existing error codes. - - `pylsp.plugins.ruff.format`: List of error codes to fix during formatting. The default is `["I"]`, any additional codes are appended to this list. - - `pylsp.plugins.ruff.unsafeFixes`: boolean that enables/disables fixes that are marked "unsafe" by `ruff`. `false` by default. - - `pylsp.plugins.ruff.severities`: Dictionary of custom severity levels for specific codes, see [below](#custom-severities). +file is present in the project, `python-lsp-ruff` will ignore specific options (see below). + +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 optionsL + +```lua +pylsp = { + plugins = { + ruff = { + enabled = true, -- Enable the plugin + executable = "", -- Custom path to ruff + path = "", -- Custom config for ruff to use + extendSelect = { "I" }, -- Rules that are additionally used by ruff + extendIgnore = { "C90" }, -- Rules that are additionally ignored by ruff + 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 + + -- Rules that are ignored when a pyproject.toml or ruff.toml is present: + lineLength = 88, -- Line length to pass to ruff checking and formatting + exclude = { "__about__.py" }, -- Files to be excluded by ruff checking + select = { "F" }, -- Rules to be enabled by ruff + ignore = { "D210" }, -- Rules to be ignored by ruff + perFileIgnores = { ["__init__.py"] = "CPY001" }, -- Rules that should be ignored for specific files + preview = false, -- Whether to enable the preview style linting and formatting. + targetVersion = "py310", -- The minimum python version to target (applies for both linting and formatting). + }, + } +} +``` For more information on the configuration visit [Ruff's homepage](https://beta.ruff.rs/docs/configuration/). @@ -86,5 +76,20 @@ This default can be changed through the `pylsp.plugins.ruff.severities` option, For more information on the diagnostic severities please refer to [the official LSP reference](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#diagnosticSeverity). -Note that `python-lsp-ruff` does *not* accept regex, and it will *not* check whether the error code exists. If the custom severity level is not displayed, -please check first that the error code is correct and that the given value is one of the possible keys from above. +With `v2.0.0` it is also possible to use patterns to match codes. Rules match if the error code starts with the given pattern. If multiple patterns match the error code, `python-lsp-ruff` chooses the one with the most amount of matching characters. + + +## Code formatting + +With `python-lsp-ruff>1.6.0` formatting is done using [ruffs own formatter](https://docs.astral.sh/ruff/formatter/). +In addition, rules that should be fixed during the `textDocument/formatting` request can be added with the `format` option. + +Coming from previous versions the only change is that `isort` rules are **not** applied by default. +To enable sorting of imports using ruff's isort functionality, add `"I"` to the list of `format` rules. + + +## Code actions + +`python-lsp-ruff` supports code actions as given by possible fixes by `ruff`. `python-lsp-ruff` also supports [unsafe fixes](https://docs.astral.sh/ruff/linter/#fix-safety). +Fixes considered unsafe by `ruff` are marked `(unsafe)` in the code action. +The `Fix all` code action *only* consideres safe fixes. diff --git a/pylsp_ruff/plugin.py b/pylsp_ruff/plugin.py index a36babf..1df0461 100644 --- a/pylsp_ruff/plugin.py +++ b/pylsp_ruff/plugin.py @@ -1,3 +1,4 @@ +import enum import json import logging import re @@ -45,6 +46,7 @@ r"(?::\s?(?P([A-Z]+[0-9]+(?:[,\s]+)?)+))?" ) + UNNECESSITY_CODES = { "F401", # `module` imported but unused "F504", # % format unused named arguments @@ -61,6 +63,29 @@ } +class Subcommand(str, enum.Enum): + CHECK = "check" + FORMAT = "format" + + def __str__(self) -> str: + return self.value + + def build_args( + self, + document_path: str, + settings: PluginSettings, + fix: bool = False, + extra_arguments: Optional[List[str]] = None, + ) -> List[str]: + if self == Subcommand.CHECK: + return build_check_arguments(document_path, settings, fix, extra_arguments) + elif self == Subcommand.FORMAT: + return build_format_arguments(document_path, settings, extra_arguments) + else: + logging.warn(f"subcommand without argument builder '{self}'") + return [] + + @hookimpl def pylsp_settings(): log.debug("Initializing pylsp_ruff") @@ -103,8 +128,19 @@ def pylsp_format_document(workspace: Workspace, document: Document) -> Generator 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), + document_path=document.path, + document_source=new_text, + fix=True, + ) + # Avoid applying empty text edit - if new_text == source: + if not new_text or new_text == source: return range = Range( @@ -171,10 +207,18 @@ def create_diagnostic(check: RuffCheck, settings: PluginSettings) -> Diagnostic: if check.code == "E999" or check.code[0] == "F": severity = DiagnosticSeverity.Error - # Override severity with custom severity if possible, use default otherwise + # Check if check.code starts contained in given severities if settings.severities is not None: - custom_sev = settings.severities.get(check.code, None) - if custom_sev is not None: + _custom_sev = [ + sev + for pat, sev in sorted( + settings.severities.items(), key=lambda key: (len(key), key) + ) + if check.code.startswith(pat) + ] + + if _custom_sev: + custom_sev = _custom_sev[-1] severity = DIAGNOSTIC_SEVERITIES.get(custom_sev, severity) tags = [] @@ -238,9 +282,10 @@ def pylsp_code_actions( if diagnostic.data: # Has fix fix = converter.structure(diagnostic.data, RuffFix) - # Ignore fix if marked as unsafe and unsafe_fixes are disabled - if fix.applicability != "safe" and not settings.unsafe_fixes: - continue + if fix.applicability == "unsafe": + if not settings.unsafe_fixes: + continue + fix.message += " (unsafe)" if diagnostic.code == "I001": code_actions.append( @@ -359,6 +404,9 @@ def create_fix_all_code_action( title = "Ruff: Fix All" kind = CodeActionKind.SourceFixAll + # No unsafe fixes for 'Fix all', see https://github.com/python-lsp/python-lsp-ruff/issues/55 + settings.unsafe_fixes = False + new_text = run_ruff_fix(document=document, settings=settings) range = Range( start=Position(line=0, character=0), @@ -395,6 +443,7 @@ def run_ruff_check(document: Document, settings: PluginSettings) -> List[RuffChe document_path=document.path, document_source=document.source, settings=settings, + subcommand=Subcommand.CHECK, ) try: result = json.loads(result) @@ -418,26 +467,19 @@ def run_ruff_format( document_path: str, document_source: str, ) -> str: - fixable_codes = ["I"] - if settings.format: - fixable_codes.extend(settings.format) - extra_arguments = [ - f"--fixable={','.join(fixable_codes)}", - ] - result = run_ruff( + return run_ruff( settings=settings, document_path=document_path, document_source=document_source, - fix=True, - extra_arguments=extra_arguments, + subcommand=Subcommand.FORMAT, ) - return result def run_ruff( settings: PluginSettings, document_path: str, document_source: str, + subcommand: Subcommand = Subcommand.CHECK, fix: bool = False, extra_arguments: Optional[List[str]] = None, ) -> str: @@ -453,6 +495,8 @@ def run_ruff( document_source : str Document source or to apply ruff on. Needed when the source differs from the file source, e.g. during formatting. + subcommand: Subcommand + The ruff subcommand to run. Default = Subcommand.CHECK. fix : bool Whether to run fix or no-fix. extra_arguments : List[str] @@ -463,7 +507,8 @@ def run_ruff( String containing the result in json format. """ executable = settings.executable - arguments = build_arguments(document_path, settings, fix, extra_arguments) + + arguments = subcommand.build_args(document_path, settings, fix, extra_arguments) if executable is not None: log.debug(f"Calling {executable} with args: {arguments} on '{document_path}'") @@ -474,7 +519,7 @@ def run_ruff( except Exception: log.error(f"Can't execute ruff with given executable '{executable}'.") else: - cmd = [sys.executable, "-m", "ruff"] + 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()) @@ -485,14 +530,14 @@ def run_ruff( return stdout.decode() -def build_arguments( +def build_check_arguments( document_path: str, settings: PluginSettings, fix: bool = False, extra_arguments: Optional[List[str]] = None, ) -> List[str]: """ - Build arguments for ruff. + Build arguments for ruff check. Parameters ---------- @@ -516,6 +561,7 @@ def build_arguments( args.append("--exit-zero") # Use the json formatting for easier evaluation args.append("--output-format=json") + args.append("--extension=ipynb:python") if fix: args.append("--fix") else: @@ -533,6 +579,9 @@ def build_arguments( if settings.line_length: args.append(f"--line-length={settings.line_length}") + if settings.preview: + args.append("--preview") + if settings.unsafe_fixes: args.append("--unsafe-fixes") @@ -551,6 +600,9 @@ def build_arguments( if settings.extend_ignore: args.append(f"--extend-ignore={','.join(settings.extend_ignore)}") + if settings.target_version: + args.append(f"--target-version={settings.target_version}") + if settings.per_file_ignores: for path, errors in settings.per_file_ignores.items(): if not PurePath(document_path).match(path): @@ -565,6 +617,60 @@ def build_arguments( return args +def build_format_arguments( + document_path: str, + settings: PluginSettings, + extra_arguments: Optional[List[str]] = None, +) -> List[str]: + """ + Build arguments for ruff format. + + Parameters + ---------- + document : pylsp.workspace.Document + Document to apply ruff on. + settings : PluginSettings + Settings to use for arguments to pass to ruff. + extra_arguments : List[str] + Extra arguments to pass to ruff. + + Returns + ------- + List containing the arguments. + """ + args = [] + # Suppress update announcements + args.append("--quiet") + + # Always force excludes + args.append("--force-exclude") + # Pass filename to ruff for per-file-ignores, catch unsaved + if document_path != "": + args.append(f"--stdin-filename={document_path}") + + if settings.config: + args.append(f"--config={settings.config}") + + if settings.exclude: + args.append(f"--exclude={','.join(settings.exclude)}") + + if settings.preview: + args.append("--preview") + + if settings.line_length: + args.append(f"--line-length={settings.line_length}") + + if settings.target_version: + args.append(f"--target-version={settings.target_version}") + + if extra_arguments: + args.extend(extra_arguments) + + args.extend(["--", "-"]) + + return args + + def load_settings(workspace: Workspace, document_path: str) -> PluginSettings: """ Load settings from pyproject.toml file in the project path. diff --git a/pylsp_ruff/settings.py b/pylsp_ruff/settings.py index 0ccda7e..2345ce1 100644 --- a/pylsp_ruff/settings.py +++ b/pylsp_ruff/settings.py @@ -23,10 +23,13 @@ class PluginSettings: format: Optional[List[str]] = None + preview: bool = False unsafe_fixes: bool = False severities: Optional[Dict[str, str]] = None + target_version: Optional[str] = None + def to_camel_case(snake_str: str) -> str: components = snake_str.split("_") diff --git a/pyproject.toml b/pyproject.toml index cfdf720..85fa3e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,14 +7,15 @@ name = "python-lsp-ruff" authors = [ {name = "Julian Hossbach", email = "julian.hossbach@gmx.de"} ] -version = "1.6.0" +version = "2.0.0" description = "Ruff linting plugin for pylsp" readme = "README.md" -requires-python = ">=3.7" +requires-python = ">=3.8" license = {text = "MIT"} dependencies = [ - "ruff>=0.1.0, <0.2.0", + "ruff>=0.1.5, <0.2.0", "python-lsp-server", + "cattrs!=23.2.1", "lsprotocol>=2022.0.0a1", "tomli>=1.1.0; python_version < '3.11'", ] diff --git a/tests/test_code_actions.py b/tests/test_code_actions.py index 063b536..f304e7a 100644 --- a/tests/test_code_actions.py +++ b/tests/test_code_actions.py @@ -45,7 +45,7 @@ def f(): codeactions = [ "Ruff (F401): Remove unused import: `os`", "Ruff (F401): Disable for this line", - "Ruff (F841): Remove assignment to unused variable `a`", + "Ruff (F841): Remove assignment to unused variable `a` (unsafe)", "Ruff (F841): Disable for this line", "Ruff: Fix All", ] @@ -70,7 +70,9 @@ def temp_document(doc_text, workspace): def test_ruff_code_actions(workspace): _, doc = temp_document(codeaction_str, workspace) - workspace._config.update({"plugins": {"ruff": {"select": ["F"]}}}) + workspace._config.update( + {"plugins": {"ruff": {"select": ["F"], "unsafeFixes": True}}} + ) diags = ruff_lint.pylsp_lint(workspace, doc) range_ = cattrs.unstructure( Range(start=Position(line=0, character=0), end=Position(line=0, character=0)) @@ -79,8 +81,8 @@ def test_ruff_code_actions(workspace): workspace._config, workspace, doc, range=range_, context={"diagnostics": diags} ) actions = converter.structure(actions, List[CodeAction]) - for action in actions: - assert action.title in codeactions + action_titles = list(map(lambda action: action.title, actions)) + assert sorted(codeactions) == sorted(action_titles) def test_import_action(workspace): @@ -104,8 +106,8 @@ def test_import_action(workspace): workspace._config, workspace, doc, range=range_, context={"diagnostics": diags} ) actions = converter.structure(actions, List[CodeAction]) - for action in actions: - assert action.title in codeactions_import + action_titles = list(map(lambda action: action.title, actions)) + assert sorted(codeactions_import) == sorted(action_titles) def test_fix_all(workspace): @@ -148,37 +150,3 @@ def f(): settings = ruff_lint.load_settings(workspace, doc.path) fixed_str = ruff_lint.run_ruff_fix(doc, settings) assert fixed_str == expected_str_safe - - -def test_format_document_default_settings(workspace): - _, doc = temp_document(import_str, workspace) - settings = ruff_lint.load_settings(workspace, doc.path) - formatted_str = ruff_lint.run_ruff_format( - settings, document_path=doc.path, document_source=doc.source - ) - assert formatted_str == import_str - - -def test_format_document_settings(workspace): - expected_str = dedent( - """ - import os - import pathlib - """ - ) - workspace._config.update( - { - "plugins": { - "ruff": { - "select": ["I"], - "format": ["I001"], - } - } - } - ) - _, doc = temp_document(import_str, workspace) - settings = ruff_lint.load_settings(workspace, doc.path) - formatted_str = ruff_lint.run_ruff_format( - settings, document_path=doc.path, document_source=doc.source - ) - assert formatted_str == expected_str diff --git a/tests/test_ruff_format.py b/tests/test_ruff_format.py new file mode 100644 index 0000000..817d7be --- /dev/null +++ b/tests/test_ruff_format.py @@ -0,0 +1,114 @@ +import contextlib +import tempfile +import textwrap as tw +from typing import Any, List, Mapping, Optional +from unittest.mock import Mock + +import pytest +from pylsp import uris +from pylsp.config.config import Config +from pylsp.workspace import Document, Workspace + +import pylsp_ruff.plugin as plugin + +_UNSORTED_IMPORTS = tw.dedent( + """ + from thirdparty import x + import io + import asyncio + """ +).strip() + +_SORTED_IMPORTS = tw.dedent( + """ + import asyncio + import io + + from thirdparty import x + """ +).strip() + +_UNFORMATTED_CODE = tw.dedent( + """ + def foo(): pass + def bar(): pass + """ +).strip() + +_FORMATTED_CODE = tw.dedent( + """ + def foo(): + pass + + + def bar(): + pass + """ +).strip() + + +@pytest.fixture() +def workspace(tmp_path): + """Return a workspace.""" + ws = Workspace(tmp_path.absolute().as_uri(), Mock()) + ws._config = Config(ws.root_uri, {}, 0, {}) + return ws + + +def temp_document(doc_text, workspace): + with tempfile.NamedTemporaryFile( + mode="w", dir=workspace.root_path, delete=False + ) as temp_file: + name = temp_file.name + temp_file.write(doc_text) + doc = Document(uris.from_fs_path(name), workspace) + return name, doc + + +def run_plugin_format(workspace: Workspace, doc: Document) -> str: + class TestResult: + result: Optional[List[Mapping[str, Any]]] + + def __init__(self): + self.result = None + + def get_result(self): + return self.result + + def force_result(self, r): + self.result = r + + generator = plugin.pylsp_format_document(workspace, doc) + result = TestResult() + with contextlib.suppress(StopIteration): + generator.send(None) + generator.send(result) + + if result.result: + return result.result[0]["newText"] + return pytest.fail() + + +def test_ruff_format_only(workspace): + txt = f"{_UNSORTED_IMPORTS}\n{_UNFORMATTED_CODE}" + want = f"{_UNSORTED_IMPORTS}\n\n\n{_FORMATTED_CODE}\n" + _, doc = temp_document(txt, workspace) + got = run_plugin_format(workspace, doc) + assert want == got + + +def test_ruff_format_and_sort_imports(workspace): + txt = f"{_UNSORTED_IMPORTS}\n{_UNFORMATTED_CODE}" + want = f"{_SORTED_IMPORTS}\n\n\n{_FORMATTED_CODE}\n" + _, doc = temp_document(txt, workspace) + workspace._config.update( + { + "plugins": { + "ruff": { + "format": ["I001"], + } + } + } + ) + got = run_plugin_format(workspace, doc) + assert want == got diff --git a/tests/test_ruff_lint.py b/tests/test_ruff_lint.py index 41026fb..2933d26 100644 --- a/tests/test_ruff_lint.py +++ b/tests/test_ruff_lint.py @@ -178,9 +178,11 @@ def f(): str(sys.executable), "-m", "ruff", + "check", "--quiet", "--exit-zero", "--output-format=json", + "--extension=ipynb:python", "--no-fix", "--force-exclude", f"--stdin-filename={os.path.join(workspace.root_path, '__init__.py')}", @@ -193,7 +195,7 @@ def f(): "plugins": { "ruff": { "extendIgnore": ["D104"], - "severities": {"E402": "E", "D103": "I"}, + "severities": {"E402": "E", "D": "I", "D1": "H"}, } } } @@ -215,7 +217,7 @@ def f(): if diag["code"] == "E402": assert diag["severity"] == 1 if diag["code"] == "D103": - assert diag["severity"] == 3 + assert diag["severity"] == 4 # Should take "D1" over "D" # Excludes doc_uri = uris.from_fs_path(os.path.join(workspace.root_path, "blah/__init__.py")) @@ -242,3 +244,23 @@ def f(): assert diag["code"] != "F401" os.unlink(os.path.join(workspace.root_path, "pyproject.toml")) + + +def test_notebook_input(workspace): + doc_str = r""" +print('hi') +import os +def f(): + a = 2 +""" + # attribute the python code to a notebook file name per jupyterlab-lsp + doc_uri = uris.from_fs_path(os.path.join(workspace.root_path, "Untitled.ipynb")) + workspace.put_document(doc_uri, doc_str) + doc = workspace.get_document(doc_uri) + + diags = ruff_lint.pylsp_lint(workspace, doc) + diag_codes = [diag["code"] for diag in diags] + assert "E999" not in diag_codes + assert "E402" in diag_codes + assert "F401" in diag_codes + assert "F841" in diag_codes