diff --git a/README.md b/README.md index 249b0a2..2e92c51 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,7 @@ pylsp = { 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). + virtualDocumentsDir = ".virtual_documents", -- If using JupyterLab, point to the location of the virtual documents directory. }, } } @@ -104,6 +105,7 @@ pylsp = { }, "preview": false, "targetVersion": "py310" + "virtualDocumentsDir": ".virtual_documents", } } } diff --git a/pylsp_ruff/plugin.py b/pylsp_ruff/plugin.py index 4a05017..e834d11 100644 --- a/pylsp_ruff/plugin.py +++ b/pylsp_ruff/plugin.py @@ -2,6 +2,7 @@ import importlib.util import json import logging +import os import re import shutil import sys @@ -573,6 +574,24 @@ def run_ruff( return stdout.decode() +def strip_virtual_documents_path( + document_path: str, virtual_documents_dir: Optional[str] +) -> str: + """Strip the virtual documents path from the current document path. + Returns the unchanged document_path if virtual_documents_dir is not in the path. + """ + if not virtual_documents_dir: + return document_path + + virt_parts = PurePath(virtual_documents_dir).parts + parts = PurePath(document_path).parts + n = len(virt_parts) + for i in range(len(parts) - n + 1): + if parts[i : i + n] == virt_parts: + return str(PurePath(*parts[:i], *parts[i + n :])) + return document_path + + def build_check_arguments( document_path: str, settings: PluginSettings, @@ -614,7 +633,9 @@ def build_check_arguments( args.append("--force-exclude") # Pass filename to ruff for per-file-ignores, catch unsaved if document_path != "": - args.append(f"--stdin-filename={document_path}") + args.append( + f"--stdin-filename={strip_virtual_documents_path(document_path, settings.virtual_documents_dir)}" + ) if settings.config: args.append(f"--config={settings.config}") @@ -692,7 +713,9 @@ def build_format_arguments( args.append("--force-exclude") # Pass filename to ruff for per-file-ignores, catch unsaved if document_path != "": - args.append(f"--stdin-filename={document_path}") + args.append( + f"--stdin-filename={strip_virtual_documents_path(document_path, settings.virtual_documents_dir)}" + ) if settings.config: args.append(f"--config={settings.config}") @@ -754,6 +777,11 @@ def load_settings(workspace: Workspace, document_path: str) -> PluginSettings: workspace.root_path, document_path, ["ruff.toml", ".ruff.toml"] ) + if not plugin_settings.virtual_documents_dir: + plugin_settings.virtual_documents_dir = os.getenv( + "JP_LSP_VIRTUAL_DIR", ".virtual_documents" + ) + # 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.") @@ -768,6 +796,7 @@ def load_settings(workspace: Workspace, document_path: str) -> PluginSettings: format=plugin_settings.format, severities=plugin_settings.severities, unfixable=plugin_settings.unfixable, + virtual_documents_dir=plugin_settings.virtual_documents_dir, ) return plugin_settings diff --git a/pylsp_ruff/settings.py b/pylsp_ruff/settings.py index c274dac..e6fd30e 100644 --- a/pylsp_ruff/settings.py +++ b/pylsp_ruff/settings.py @@ -33,6 +33,8 @@ class PluginSettings: target_version: Optional[str] = None + virtual_documents_dir: Optional[str] = None + def to_camel_case(snake_str: str) -> str: components = snake_str.split("_") diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e3bc837 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,52 @@ +import tempfile +from unittest.mock import Mock + +import pytest +from pylsp import uris +from pylsp.config.config import Config +from pylsp.workspace import Document, Workspace + + +@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 + + +@pytest.fixture() +def notebook_workspace(tmp_path): + """Workspace with a notebook and `ruff.toml`. + + Structure created under `tmp_path`: + . + ├── .virtual_documents + │ └── foo + │ └── bar.ipynb + └── foo + ├── bar.ipynb + └── ruff.toml + """ + virtual_dir = tmp_path / ".virtual_documents" / "foo" + real_dir = tmp_path / "foo" + virtual_dir.mkdir(parents=True) + real_dir.mkdir(parents=True) + + (virtual_dir / "bar.ipynb").write_text("") + (real_dir / "bar.ipynb").write_text("") + (real_dir / "ruff.toml").write_text("") + + 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 diff --git a/tests/test_ruff_format.py b/tests/test_ruff_format.py index 3d7ef85..b9a871f 100644 --- a/tests/test_ruff_format.py +++ b/tests/test_ruff_format.py @@ -1,12 +1,11 @@ import contextlib -import tempfile +import os import textwrap as tw from typing import Any, List, Mapping, Optional -from unittest.mock import Mock +from unittest.mock import patch -import pytest +from conftest import temp_document from pylsp import uris -from pylsp.config.config import Config from pylsp.workspace import Document, Workspace import pylsp_ruff.plugin as plugin @@ -47,24 +46,6 @@ def bar(): ).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]]] @@ -106,6 +87,29 @@ def test_ruff_format_disabled(workspace): assert got == "" +def test_ruff_format_strips_virtual_documents_path(notebook_workspace): + virtual_path = os.path.join( + notebook_workspace.root_path, ".virtual_documents", "foo", "bar.ipynb" + ) + expected_stripped = os.path.join(notebook_workspace.root_path, "foo", "bar.ipynb") + doc_uri = uris.from_fs_path(virtual_path) + notebook_workspace.put_document(doc_uri, _UNFORMATTED_CODE) + doc = notebook_workspace.get_document(doc_uri) + + with patch("pylsp_ruff.plugin.Popen") as popen_mock: + mock_instance = popen_mock.return_value + mock_instance.communicate.return_value = [bytes(), bytes()] + run_plugin_format(notebook_workspace, doc) + + format_calls = [ + call for call in popen_mock.call_args_list if "format" in call[0][0] + ] + assert format_calls, "ruff format was not invoked" + cmd = format_calls[0][0][0] + assert f"--stdin-filename={expected_stripped}" in cmd + assert f"--stdin-filename={virtual_path}" not in cmd + + 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" diff --git a/tests/test_ruff_lint.py b/tests/test_ruff_lint.py index 52ef6a5..c3c9a35 100644 --- a/tests/test_ruff_lint.py +++ b/tests/test_ruff_lint.py @@ -5,12 +5,11 @@ import stat import sys import tempfile -from unittest.mock import Mock, patch +from unittest.mock import patch -import pytest from pylsp import lsp, uris -from pylsp.config.config import Config -from pylsp.workspace import Document, Workspace +from pylsp.workspace import Document +from conftest import temp_document import pylsp_ruff.plugin as ruff_lint @@ -29,24 +28,6 @@ def using_const(): """ -@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) @@ -276,6 +257,38 @@ def f(): os.unlink(os.path.join(workspace.root_path, "pyproject.toml")) +def test_strip_virtual_documents_path_no_match(): + path = "/work/foo/bar.py" + assert ruff_lint.strip_virtual_documents_path(path, ".virtual_documents") == path + + +def test_strip_virtual_documents_path_empty(): + path = "/work/.virtual_documents/foo/bar.ipynb" + assert ruff_lint.strip_virtual_documents_path(path, None) == path + assert ruff_lint.strip_virtual_documents_path(path, "") == path + + +def test_ruff_lint_strips_virtual_documents_path(notebook_workspace): + virtual_path = os.path.join( + notebook_workspace.root_path, ".virtual_documents", "foo", "bar.ipynb" + ) + expected_stripped = os.path.join(notebook_workspace.root_path, "foo", "bar.ipynb") + doc_uri = uris.from_fs_path(virtual_path) + notebook_workspace.put_document(doc_uri, "import os\n") + doc = notebook_workspace.get_document(doc_uri) + + with patch("pylsp_ruff.plugin.Popen") as popen_mock: + mock_instance = popen_mock.return_value + mock_instance.communicate.return_value = [bytes(), bytes()] + ruff_lint.pylsp_lint(notebook_workspace, doc) + + (call_args,) = popen_mock.call_args[0] + assert f"--stdin-filename={expected_stripped}" in call_args + assert ( + f"--stdin-filename={virtual_path}" not in call_args + ), "virtual_documents prefix was not stripped" + + def test_notebook_input(workspace): doc_str = r""" print('hi')