pax_global_header00006660000000000000000000000064145137155030014516gustar00rootroot0000000000000052 comment=b71fcd33a34ea759c79d90d080cebe365c776132 python-lsp-ruff-1.5.3/000077500000000000000000000000001451371550300146015ustar00rootroot00000000000000python-lsp-ruff-1.5.3/.github/000077500000000000000000000000001451371550300161415ustar00rootroot00000000000000python-lsp-ruff-1.5.3/.github/workflows/000077500000000000000000000000001451371550300201765ustar00rootroot00000000000000python-lsp-ruff-1.5.3/.github/workflows/python.yml000066400000000000000000000010761451371550300222460ustar00rootroot00000000000000name: Python on: push: branches: [ "main" ] pull_request: jobs: build: runs-on: ubuntu-latest strategy: matrix: python-version: [3.7, 3.8, 3.9, "3.10"] steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 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 - name: Tests run: pytest -v tests/ python-lsp-ruff-1.5.3/.gitignore000066400000000000000000000000311451371550300165630ustar00rootroot00000000000000__pycache__/ *.egg-info/ python-lsp-ruff-1.5.3/.pre-commit-config.yaml000066400000000000000000000007431451371550300210660ustar00rootroot00000000000000repos: - repo: https://github.com/psf/black rev: 22.8.0 hooks: - id: black - repo: https://github.com/pre-commit/mirrors-mypy rev: v0.991 hooks: - id: mypy args: [--ignore-missing-imports] - repo: https://github.com/charliermarsh/ruff-pre-commit rev: v0.0.158 hooks: - id: ruff - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.1.0 hooks: - id: check-merge-conflict - id: debug-statements python-lsp-ruff-1.5.3/LICENSE000066400000000000000000000021621451371550300156070ustar00rootroot00000000000000MIT License Copyright 2017-2020 Palantir Technologies, Inc. Copyright 2021- Python Language Server Contributors. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. python-lsp-ruff-1.5.3/README.md000066400000000000000000000104201451371550300160550ustar00rootroot00000000000000# python-lsp-ruff [![PyPi](https://img.shields.io/pypi/v/python-lsp-ruff.svg)](https://pypi.org/project/python-lsp-ruff) [![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), an extremely fast Python linter written in Rust. ## Install In the same `virtualenv` as `python-lsp-server`: ```shell pip install python-lsp-ruff ``` There also exists an [AUR package](https://aur.archlinux.org/packages/python-lsp-ruff). # Usage 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. Assumed to be in PATH 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.severities`: Dictionary of custom severity levels for specific codes, see [below](#custom-severities). For more information on the configuration visit [Ruff's homepage](https://beta.ruff.rs/docs/configuration/). ## Custom severities By default, all diagnostics are marked as warning, except for `"E999"` and all error codes starting with `"F"`, which are displayed as errors. This default can be changed through the `pylsp.plugins.ruff.severities` option, which takes the error code as a key and any of `"E"`, `"W"`, `"I"` and `"H"` to be displayed as errors, warnings, information and hints, respectively. 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. python-lsp-ruff-1.5.3/pylsp_ruff/000077500000000000000000000000001451371550300167725ustar00rootroot00000000000000python-lsp-ruff-1.5.3/pylsp_ruff/__init__.py000066400000000000000000000000001451371550300210710ustar00rootroot00000000000000python-lsp-ruff-1.5.3/pylsp_ruff/plugin.py000066400000000000000000000421001451371550300206370ustar00rootroot00000000000000import json import logging import re import sys from pathlib import PurePath from subprocess import PIPE, Popen from typing import Dict, Generator, List, Optional if sys.version_info >= (3, 11): import tomllib else: import tomli as tomllib from lsprotocol.types import ( CodeAction, CodeActionContext, CodeActionKind, Diagnostic, DiagnosticSeverity, DiagnosticTag, Position, Range, TextEdit, WorkspaceEdit, ) from pylsp import hookimpl from pylsp._utils import find_parents from pylsp.config.config import Config from pylsp.workspace import Document, Workspace from pylsp_ruff.ruff import Check as RuffCheck from pylsp_ruff.ruff import Fix as RuffFix from pylsp_ruff.settings import PluginSettings, get_converter log = logging.getLogger(__name__) logging.getLogger("blib2to3").setLevel(logging.ERROR) converter = get_converter() DIAGNOSTIC_SOURCE = "ruff" # shamelessly borrowed from: # https://github.com/charliermarsh/ruff-lsp/blob/2a0e2ea3afefdbf00810b8df91030c1c6b59d103/ruff_lsp/server.py#L214 NOQA_REGEX = re.compile( r"(?i:# (?:(?:ruff|flake8): )?(?Pnoqa))" r"(?::\s?(?P([A-Z]+[0-9]+(?:[,\s]+)?)+))?" ) UNNECESSITY_CODES = { "F401", # `module` imported but unused "F504", # % format unused named arguments "F522", # .format(...) unused named arguments "F523", # .format(...) unused positional arguments "F841", # local variable `name` is assigned to but never used } DIAGNOSTIC_SEVERITIES = { "E": DiagnosticSeverity.Error, "W": DiagnosticSeverity.Warning, "I": DiagnosticSeverity.Information, "H": DiagnosticSeverity.Hint, } @hookimpl def pylsp_settings(): log.debug("Initializing pylsp_ruff") # This plugin disables some enabled-by-default plugins that duplicate Ruff # functionality settings = { "plugins": { "ruff": PluginSettings(), "pyflakes": {"enabled": False}, "mccabe": {"enabled": False}, "pycodestyle": {"enabled": False}, "pyls_isort": {"enabled": False}, } } return converter.unstructure(settings) @hookimpl(hookwrapper=True) def pylsp_format_document(workspace: Workspace, document: Document) -> Generator: """ Provide formatting through ruff. Parameters ---------- workspace : pylsp.workspace.Workspace Current workspace. document : pylsp.workspace.Document Document to apply ruff on. """ log.debug(f"textDocument/formatting: {document}") outcome = yield result = outcome.get_result() if result: source = result[0]["newText"] else: source = document.source settings = load_settings(workspace=workspace, document_path=document.path) new_text = run_ruff_format( settings=settings, document_path=document.path, document_source=source ) # Avoid applying empty text edit if 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])) @hookimpl def pylsp_lint(workspace: Workspace, document: Document) -> List[Dict]: """ Register ruff as the linter. Parameters ---------- workspace : pylsp.workspace.Workspace Current workspace. document : pylsp.workspace.Document Document to apply ruff on. Returns ------- 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) def create_diagnostic(check: RuffCheck, settings: PluginSettings) -> Diagnostic: # Adapt range to LSP specification (zero-based) range = Range( start=Position( line=check.location.row - 1, character=check.location.column - 1, ), end=Position( line=check.end_location.row - 1, character=check.end_location.column - 1, ), ) # Ruff intends to implement severity codes in the future, # see https://github.com/charliermarsh/ruff/issues/645. severity = DiagnosticSeverity.Warning if check.code == "E999" or check.code[0] == "F": severity = DiagnosticSeverity.Error # Override severity with custom severity if possible, use default otherwise if settings.severities is not None: custom_sev = settings.severities.get(check.code, None) if custom_sev is not None: severity = DIAGNOSTIC_SEVERITIES.get(custom_sev, severity) tags = [] if check.code in UNNECESSITY_CODES: tags = [DiagnosticTag.Unnecessary] return Diagnostic( source=DIAGNOSTIC_SOURCE, code=check.code, range=range, message=check.message, severity=severity, tags=tags, data=check.fix, ) @hookimpl def pylsp_code_actions( config: Config, workspace: Workspace, document: Document, range: Dict, context: Dict, ) -> List[Dict]: """ Provide code actions through ruff. Parameters ---------- config : pylsp.config.config.Config Current workspace. workspace : pylsp.workspace.Workspace Current workspace. document : pylsp.workspace.Document Document to apply ruff on. range : Dict Range argument given by pylsp. Not used here. context : Dict CodeActionContext given as dict. Returns ------- List of dicts containing the code actions. """ log.debug(f"textDocument/codeAction: {document} {range} {context}") _context = converter.structure(context, CodeActionContext) diagnostics = _context.diagnostics code_actions = [] has_organize_imports = False for diagnostic in diagnostics: code_actions.append( create_disable_code_action(document=document, diagnostic=diagnostic) ) if diagnostic.data: # Has fix fix = converter.structure(diagnostic.data, RuffFix) if diagnostic.code == "I001": code_actions.append( create_organize_imports_code_action( document=document, diagnostic=diagnostic, fix=fix ) ) has_organize_imports = True else: code_actions.append( create_fix_code_action( document=document, diagnostic=diagnostic, fix=fix ), ) settings = load_settings(workspace=workspace, document_path=document.path) checks = run_ruff_check(document=document, settings=settings) checks_with_fixes = [c for c in checks if c.fix] checks_organize_imports = [c for c in checks_with_fixes if c.code == "I001"] if not has_organize_imports and checks_organize_imports: check = checks_organize_imports[0] fix = check.fix # type: ignore diagnostic = create_diagnostic(check=check, settings=settings) code_actions.extend( [ create_organize_imports_code_action( document=document, diagnostic=diagnostic, fix=fix ), create_disable_code_action(document=document, diagnostic=diagnostic), ] ) if checks_with_fixes: code_actions.append( create_fix_all_code_action(document=document, settings=settings), ) return converter.unstructure(code_actions) def create_fix_code_action( document: Document, diagnostic: Diagnostic, fix: RuffFix, ) -> CodeAction: title = f"Ruff ({diagnostic.code}): {fix.message}" kind = CodeActionKind.QuickFix text_edits = create_text_edits(fix) workspace_edit = WorkspaceEdit(changes={document.uri: text_edits}) return CodeAction( title=title, kind=kind, diagnostics=[diagnostic], edit=workspace_edit, ) def create_disable_code_action( document: Document, diagnostic: Diagnostic, ) -> CodeAction: title = f"Ruff ({diagnostic.code}): Disable for this line" kind = CodeActionKind.QuickFix line = document.lines[diagnostic.range.start.line].rstrip("\r\n") match = NOQA_REGEX.search(line) has_noqa = match is not None has_codes = match and match.group("codes") is not None # `foo # noqa: OLD` -> `foo # noqa: OLD,NEW` if has_noqa and has_codes: new_line = f"{line},{diagnostic.code}" # `foo # noqa` -> `foo # noqa: NEW` elif has_noqa: new_line = f"{line}: {diagnostic.code}" # `foo` -> `foo # noqa: NEW` else: new_line = f"{line} # noqa: {diagnostic.code}" range = Range( start=Position(line=diagnostic.range.start.line, character=0), end=Position(line=diagnostic.range.start.line, character=len(line)), ) text_edit = TextEdit(range=range, new_text=new_line) workspace_edit = WorkspaceEdit(changes={document.uri: [text_edit]}) return CodeAction( title=title, kind=kind, diagnostics=[diagnostic], edit=workspace_edit, ) def create_organize_imports_code_action( document: Document, diagnostic: Diagnostic, fix: RuffFix, ) -> CodeAction: title = f"Ruff: {fix.message}" kind = CodeActionKind.SourceOrganizeImports text_edits = create_text_edits(fix) workspace_edit = WorkspaceEdit(changes={document.uri: text_edits}) return CodeAction( title=title, kind=kind, diagnostics=[diagnostic], edit=workspace_edit, ) def create_fix_all_code_action( document: Document, settings: PluginSettings, ) -> CodeAction: title = "Ruff: Fix All" kind = CodeActionKind.SourceFixAll new_text = run_ruff_fix(document=document, settings=settings) 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) workspace_edit = WorkspaceEdit(changes={document.uri: [text_edit]}) return CodeAction( title=title, kind=kind, edit=workspace_edit, ) def create_text_edits(fix: RuffFix) -> List[TextEdit]: edits = [] for edit in fix.edits: range = Range( start=Position( line=edit.location.row - 1, character=edit.location.column - 1, ), end=Position( line=edit.end_location.row - 1, character=edit.end_location.column - 1, ), ) edits.append(TextEdit(range=range, new_text=edit.content)) return edits def run_ruff_check(document: Document, settings: PluginSettings) -> List[RuffCheck]: result = run_ruff( document_path=document.path, document_source=document.source, settings=settings, ) try: result = json.loads(result) except json.JSONDecodeError: result = [] # type: ignore return converter.structure(result, List[RuffCheck]) def run_ruff_fix(document: Document, settings: PluginSettings) -> str: result = run_ruff( document_path=document.path, document_source=document.source, fix=True, settings=settings, ) return result def run_ruff_format( settings: PluginSettings, 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( settings=settings, document_path=document_path, document_source=document_source, fix=True, extra_arguments=extra_arguments, ) return result def run_ruff( settings: PluginSettings, document_path: str, document_source: str, fix: bool = False, extra_arguments: Optional[List[str]] = None, ) -> str: """ Run ruff on the given document and the given arguments. Parameters ---------- settings : PluginSettings Settings to use. document_path : str Path to file to run ruff on. document_source : str Document source or to apply ruff on. Needed when the source differs from the file source, e.g. during formatting. fix : bool Whether to run fix or no-fix. extra_arguments : List[str] Extra arguments to pass to ruff. Returns ------- String containing the result in json format. """ executable = settings.executable arguments = build_arguments(document_path, settings, fix, extra_arguments) log.debug(f"Calling {executable} with args: {arguments} on '{document_path}'") try: cmd = [executable] cmd.extend(arguments) p = Popen(cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE) except Exception: log.debug(f"Can't execute {executable}. Trying with '{sys.executable} -m ruff'") cmd = [sys.executable, "-m", "ruff"] cmd.extend(arguments) p = Popen(cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE) (stdout, stderr) = p.communicate(document_source.encode()) if stderr: log.error(f"Error running ruff: {stderr.decode()}") return stdout.decode() def build_arguments( document_path: str, settings: PluginSettings, fix: bool = False, extra_arguments: Optional[List[str]] = None, ) -> List[str]: """ Build arguments for ruff. Parameters ---------- document : pylsp.workspace.Document Document to apply ruff on. settings : PluginSettings Settings to use for arguments to pass to ruff. fix : bool Whether to execute with --fix. extra_arguments : List[str] Extra arguments to pass to ruff. Returns ------- List containing the arguments. """ args = [] # Suppress update announcements args.append("--quiet") # Use the json formatting for easier evaluation args.append("--format=json") if fix: args.append("--fix") else: # Do not attempt to fix -> returns file instead of diagnostics args.append("--no-fix") # 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.line_length: args.append(f"--line-length={settings.line_length}") if settings.exclude: args.append(f"--exclude={','.join(settings.exclude)}") if settings.select: args.append(f"--select={','.join(settings.select)}") if settings.extend_select: args.append(f"--extend-select={','.join(settings.extend_select)}") if settings.ignore: args.append(f"--ignore={','.join(settings.ignore)}") if settings.extend_ignore: args.append(f"--extend-ignore={','.join(settings.extend_ignore)}") if settings.per_file_ignores: for path, errors in settings.per_file_ignores.items(): if not PurePath(document_path).match(path): continue args.append(f"--ignore={','.join(errors)}") 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. Parameters ---------- workspace : pylsp.workspace.Workspace Current workspace. document_path : str Path to the document to apply ruff on. Returns ------- PluginSettings read via lsp. """ config = workspace._config _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"] ) config_in_pyproject = False if pyproject_file: try: with open(pyproject_file[0], "rb") as f: toml_dict = tomllib.load(f) if "tool" in toml_dict and "ruff" in toml_dict["tool"]: config_in_pyproject = True except tomllib.TOMLDecodeError: log.warn("Error while parsing toml file, ignoring config.") ruff_toml = find_parents( workspace.root_path, document_path, ["ruff.toml", ".ruff.toml"] ) # Check if pyproject is present, ignore user settings if toml exists if config_in_pyproject or ruff_toml: log.debug("Found existing configuration for ruff, skipping pylsp config.") # Leave config to pyproject.toml return PluginSettings( enabled=plugin_settings.enabled, executable=plugin_settings.executable, extend_ignore=plugin_settings.extend_ignore, extend_select=plugin_settings.extend_select, format=plugin_settings.format, severities=plugin_settings.severities, ) return plugin_settings python-lsp-ruff-1.5.3/pylsp_ruff/ruff.py000066400000000000000000000006641451371550300203140ustar00rootroot00000000000000from dataclasses import dataclass from typing import List, Union @dataclass class Location: row: int column: int @dataclass class Edit: content: str location: Location end_location: Location @dataclass class Fix: edits: List[Edit] message: str @dataclass class Check: code: str message: str filename: str location: Location end_location: Location fix: Union[Fix, None] = None python-lsp-ruff-1.5.3/pylsp_ruff/settings.py000066400000000000000000000032151451371550300212050ustar00rootroot00000000000000from dataclasses import dataclass, fields from typing import Dict, List, Optional import lsprotocol.converters from cattrs.gen import make_dict_structure_fn, make_dict_unstructure_fn, override @dataclass class PluginSettings: enabled: bool = True executable: str = "ruff" config: Optional[str] = None line_length: Optional[int] = None exclude: Optional[List[str]] = None select: Optional[List[str]] = None extend_select: Optional[List[str]] = None ignore: Optional[List[str]] = None extend_ignore: Optional[List[str]] = None per_file_ignores: Optional[Dict[str, List[str]]] = None format: Optional[List[str]] = None severities: Optional[Dict[str, str]] = None def to_camel_case(snake_str: str) -> str: components = snake_str.split("_") return components[0] + "".join(x.title() for x in components[1:]) def to_camel_case_unstructure(converter, klass): return make_dict_unstructure_fn( klass, converter, **{a.name: override(rename=to_camel_case(a.name)) for a in fields(klass)}, ) def to_camel_case_structure(converter, klass): return make_dict_structure_fn( klass, converter, **{a.name: override(rename=to_camel_case(a.name)) for a in fields(klass)}, ) def get_converter(): converter = lsprotocol.converters.get_converter() unstructure_hook = to_camel_case_unstructure(converter, PluginSettings) structure_hook = to_camel_case_structure(converter, PluginSettings) converter.register_unstructure_hook(PluginSettings, unstructure_hook) converter.register_structure_hook(PluginSettings, structure_hook) return converter python-lsp-ruff-1.5.3/pyproject.toml000066400000000000000000000015631451371550300175220ustar00rootroot00000000000000[build-system] requires = ["setuptools"] build-backend = "setuptools.build_meta" [project] name = "python-lsp-ruff" authors = [ {name = "Julian Hossbach", email = "julian.hossbach@gmx.de"} ] version = "1.5.3" description = "Ruff linting plugin for pylsp" readme = "README.md" requires-python = ">=3.7" license = {text = "MIT"} dependencies = [ "ruff>=0.0.267,<0.1.0", "python-lsp-server", "lsprotocol>=2022.0.0a1", "tomli>=1.1.0; python_version < '3.11'", ] [project.optional-dependencies] dev = ["pytest", "pre-commit"] [project.entry-points.pylsp] ruff = "pylsp_ruff.plugin" [project.urls] "Homepage" = "https://github.com/python-lsp/python-lsp-ruff" "Bug Tracker" = "https://github.com/python-lsp/python-lsp-ruff/issues" [tool.pytest.ini_options] pythonpath = ["."] [tool.black] target-version = ['py36', 'py37', 'py38', 'py39', 'py310', 'py311'] line-length = 88 python-lsp-ruff-1.5.3/setup.py000066400000000000000000000001051451371550300163070ustar00rootroot00000000000000from setuptools import setup if __name__ == "__main__": setup() python-lsp-ruff-1.5.3/tests/000077500000000000000000000000001451371550300157435ustar00rootroot00000000000000python-lsp-ruff-1.5.3/tests/test_code_actions.py000066400000000000000000000101231451371550300220030ustar00rootroot00000000000000# Copyright 2017-2020 Palantir Technologies, Inc. # Copyright 2021- Python Language Server Contributors. import tempfile from textwrap import dedent from typing import List from unittest.mock import Mock import cattrs import pytest from lsprotocol.converters import get_converter from lsprotocol.types import CodeAction, Position, Range from pylsp import uris from pylsp.config.config import Config from pylsp.workspace import Document, Workspace import pylsp_ruff.plugin as ruff_lint converter = get_converter() @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 codeaction_str = dedent( """ import os def f(): a = 2 """ ) import_str = dedent( """ import pathlib import os """ ) codeactions = [ "Ruff (F401): Remove unused import: `os`", "Ruff (F401): Disable for this line", "Ruff (F841): Remove assignment to unused variable `a`", "Ruff (F841): Disable for this line", "Ruff: Fix All", ] codeactions_import = [ "Ruff: Organize imports", "Ruff: Fix All", "Ruff (I001): Disable for this line", ] 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 test_ruff_code_actions(workspace): _, doc = temp_document(codeaction_str, workspace) workspace._config.update({"plugins": {"ruff": {"select": ["F"]}}}) 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]) for action in actions: assert action.title in codeactions def test_import_action(workspace): workspace._config.update( { "plugins": { "ruff": { "extendSelect": ["I"], "extendIgnore": ["F"], } } } ) _, doc = temp_document(import_str, workspace) 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]) for action in actions: assert action.title in codeactions_import def test_fix_all(workspace): expected_str = dedent( """ def f(): pass """ ) _, doc = temp_document(codeaction_str, workspace) settings = ruff_lint.load_settings(workspace, doc.path) fixed_str = ruff_lint.run_ruff_fix(doc, settings) assert fixed_str == expected_str 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 python-lsp-ruff-1.5.3/tests/test_ruff_lint.py000066400000000000000000000156741451371550300213610ustar00rootroot00000000000000# Copyright 2017-2020 Palantir Technologies, Inc. # Copyright 2021- Python Language Server Contributors. import os import tempfile from unittest.mock import Mock, patch import pytest from pylsp import lsp, uris from pylsp.config.config import Config from pylsp.workspace import Document, Workspace import pylsp_ruff.plugin as ruff_lint DOC_URI = uris.from_fs_path(__file__) DOC = r"""import pylsp t = "TEST" def using_const(): a = 8 + 9 return t """ @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 test_ruff_unsaved(workspace): doc = Document("", workspace, DOC) diags = ruff_lint.pylsp_lint(workspace, doc) msg = "Local variable `a` is assigned to but never used" unused_var = [d for d in diags if d["message"] == msg][0] assert unused_var["source"] == "ruff" assert unused_var["code"] == "F841" assert unused_var["range"]["start"] == {"line": 5, "character": 4} assert unused_var["range"]["end"] == {"line": 5, "character": 5} assert unused_var["severity"] == lsp.DiagnosticSeverity.Error assert unused_var["tags"] == [lsp.DiagnosticTag.Unnecessary] def test_ruff_lint(workspace): name, doc = temp_document(DOC, workspace) try: diags = ruff_lint.pylsp_lint(workspace, doc) msg = "Local variable `a` is assigned to but never used" unused_var = [d for d in diags if d["message"] == msg][0] assert unused_var["source"] == "ruff" assert unused_var["code"] == "F841" assert unused_var["range"]["start"] == {"line": 5, "character": 4} assert unused_var["range"]["end"] == {"line": 5, "character": 5} assert unused_var["severity"] == lsp.DiagnosticSeverity.Error assert unused_var["tags"] == [lsp.DiagnosticTag.Unnecessary] finally: os.remove(name) def test_ruff_config_param(workspace): with patch("pylsp_ruff.plugin.Popen") as popen_mock: mock_instance = popen_mock.return_value mock_instance.communicate.return_value = [bytes(), bytes()] ruff_conf = "/tmp/pyproject.toml" workspace._config.update( { "plugins": { "ruff": { "config": ruff_conf, "extendSelect": ["D", "F"], "extendIgnore": ["E"], } } } ) _name, doc = temp_document(DOC, workspace) ruff_lint.pylsp_lint(workspace, doc) (call_args,) = popen_mock.call_args[0] assert "ruff" in call_args assert f"--config={ruff_conf}" in call_args assert "--extend-select=D,F" in call_args assert "--extend-ignore=E" in call_args 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()] ruff_executable = "/tmp/ruff" workspace._config.update({"plugins": {"ruff": {"executable": ruff_executable}}}) _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): """Write a ``pyproject.toml``, load it in the workspace, and return the ruff settings. This function creates a ``pyproject.toml``; you'll have to delete it yourself. """ with open( os.path.join(workspace.root_path, "pyproject.toml"), "w+", encoding="utf-8" ) as f: f.write(config_str) return ruff_lint.load_settings(workspace, doc.path) def test_ruff_settings(workspace): config_str = r"""[tool.ruff] ignore = ["F841"] exclude = [ "blah/__init__.py", "file_2.py" ] extend-select = ["D"] [tool.ruff.per-file-ignores] "test_something.py" = ["F401"] """ doc_str = r""" print('hi') import os def f(): a = 2 """ doc_uri = uris.from_fs_path(os.path.join(workspace.root_path, "__init__.py")) workspace.put_document(doc_uri, doc_str) ruff_settings = get_ruff_settings( workspace, workspace.get_document(doc_uri), config_str ) # Check that user config is ignored assert ruff_settings.executable == "ruff" empty_keys = [ "config", "line_length", "exclude", "select", "ignore", "per_file_ignores", ] for k in empty_keys: assert getattr(ruff_settings, k) is None with patch("pylsp_ruff.plugin.Popen") as popen_mock: mock_instance = popen_mock.return_value mock_instance.communicate.return_value = [bytes(), bytes()] doc = workspace.get_document(doc_uri) diags = ruff_lint.pylsp_lint(workspace, doc) call_args = popen_mock.call_args[0][0] assert call_args == [ "ruff", "--quiet", "--format=json", "--no-fix", "--force-exclude", f"--stdin-filename={os.path.join(workspace.root_path, '__init__.py')}", "--", "-", ] workspace._config.update( { "plugins": { "ruff": { "extendIgnore": ["D104"], "severities": {"E402": "E", "D103": "I"}, } } } ) diags = ruff_lint.pylsp_lint(workspace, doc) _list = [] for diag in diags: _list.append(diag["code"]) # Assert that ignore, extend-ignore and extend-select is working as intended assert "E402" in _list assert "D103" in _list assert "D104" not in _list assert "F841" not in _list # Check custom severities for diag in diags: if diag["code"] == "E402": assert diag["severity"] == 1 if diag["code"] == "D103": assert diag["severity"] == 3 # Excludes doc_uri = uris.from_fs_path(os.path.join(workspace.root_path, "blah/__init__.py")) workspace.put_document(doc_uri, doc_str) ruff_settings = get_ruff_settings( workspace, workspace.get_document(doc_uri), config_str ) doc = workspace.get_document(doc_uri) diags = ruff_lint.pylsp_lint(workspace, doc) assert diags == [] # For per-file-ignores doc_uri_per_file_ignores = uris.from_fs_path( os.path.join(workspace.root_path, "blah/test_something.py") ) workspace.put_document(doc_uri_per_file_ignores, doc_str) doc = workspace.get_document(doc_uri) diags = ruff_lint.pylsp_lint(workspace, doc) for diag in diags: assert diag["code"] != "F401" os.unlink(os.path.join(workspace.root_path, "pyproject.toml"))