Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
},
}
}
Expand Down Expand Up @@ -104,6 +105,7 @@ pylsp = {
},
"preview": false,
"targetVersion": "py310"
"virtualDocumentsDir": ".virtual_documents",
}
}
}
Expand Down
33 changes: 31 additions & 2 deletions pylsp_ruff/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import importlib.util
import json
import logging
import os
import re
import shutil
import sys
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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}")
Expand Down Expand Up @@ -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}")
Expand Down Expand Up @@ -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.")
Expand All @@ -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
2 changes: 2 additions & 0 deletions pylsp_ruff/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("_")
Expand Down
52 changes: 52 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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
48 changes: 26 additions & 22 deletions tests/test_ruff_format.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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]]]
Expand Down Expand Up @@ -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"
Expand Down
57 changes: 35 additions & 22 deletions tests/test_ruff_lint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
Expand Down Expand Up @@ -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')
Expand Down
Loading