Skip to content

Commit 26148d2

Browse files
committed
fix: Strip virtual_documents_dir from filepath
Strip away jupyterlabs virtual documents directory from the stdin-filename in order for ruff to be able to correctly find the correct config settings (see #102). Also adds the config option to set the virtual documents directory. If not set, use `JP_LSP_VIRTUAL_DIR` or `.virtual_documents`.
1 parent 00356b4 commit 26148d2

6 files changed

Lines changed: 148 additions & 46 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ pylsp = {
6969
perFileIgnores = { ["__init__.py"] = "CPY001" }, -- Rules that should be ignored for specific files
7070
preview = false, -- Whether to enable the preview style linting and formatting.
7171
targetVersion = "py310", -- The minimum python version to target (applies for both linting and formatting).
72+
virtualDocumentsDir = ".virtual_documents", -- If using JupyterLab, point to the location of the virtual documents directory.
7273
},
7374
}
7475
}
@@ -104,6 +105,7 @@ pylsp = {
104105
},
105106
"preview": false,
106107
"targetVersion": "py310"
108+
"virtualDocumentsDir": ".virtual_documents",
107109
}
108110
}
109111
}

pylsp_ruff/plugin.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import importlib.util
33
import json
44
import logging
5+
import os
56
import re
67
import shutil
78
import sys
@@ -573,6 +574,24 @@ def run_ruff(
573574
return stdout.decode()
574575

575576

577+
def strip_virtual_documents_path(
578+
document_path: str, virtual_documents_dir: Optional[str]
579+
) -> str:
580+
"""Strip the virtual documents path from the current document path.
581+
Returns the unchanged document_path if virtual_documents_dir is not in the path.
582+
"""
583+
if not virtual_documents_dir:
584+
return document_path
585+
586+
virt_parts = PurePath(virtual_documents_dir).parts
587+
parts = PurePath(document_path).parts
588+
n = len(virt_parts)
589+
for i in range(len(parts) - n + 1):
590+
if parts[i : i + n] == virt_parts:
591+
return str(PurePath(*parts[:i], *parts[i + n :]))
592+
return document_path
593+
594+
576595
def build_check_arguments(
577596
document_path: str,
578597
settings: PluginSettings,
@@ -614,7 +633,9 @@ def build_check_arguments(
614633
args.append("--force-exclude")
615634
# Pass filename to ruff for per-file-ignores, catch unsaved
616635
if document_path != "":
617-
args.append(f"--stdin-filename={document_path}")
636+
args.append(
637+
f"--stdin-filename={strip_virtual_documents_path(document_path, settings.virtual_documents_dir)}"
638+
)
618639

619640
if settings.config:
620641
args.append(f"--config={settings.config}")
@@ -692,7 +713,9 @@ def build_format_arguments(
692713
args.append("--force-exclude")
693714
# Pass filename to ruff for per-file-ignores, catch unsaved
694715
if document_path != "":
695-
args.append(f"--stdin-filename={document_path}")
716+
args.append(
717+
f"--stdin-filename={strip_virtual_documents_path(document_path, settings.virtual_documents_dir)}"
718+
)
696719

697720
if settings.config:
698721
args.append(f"--config={settings.config}")
@@ -754,6 +777,11 @@ def load_settings(workspace: Workspace, document_path: str) -> PluginSettings:
754777
workspace.root_path, document_path, ["ruff.toml", ".ruff.toml"]
755778
)
756779

780+
if not plugin_settings.virtual_documents_dir:
781+
plugin_settings.virtual_documents_dir = os.getenv(
782+
"JP_LSP_VIRTUAL_DIR", ".virtual_documents"
783+
)
784+
757785
# Check if pyproject is present, ignore user settings if toml exists
758786
if config_in_pyproject or ruff_toml:
759787
log.debug("Found existing configuration for ruff, skipping pylsp config.")
@@ -768,6 +796,7 @@ def load_settings(workspace: Workspace, document_path: str) -> PluginSettings:
768796
format=plugin_settings.format,
769797
severities=plugin_settings.severities,
770798
unfixable=plugin_settings.unfixable,
799+
virtual_documents_dir=plugin_settings.virtual_documents_dir,
771800
)
772801

773802
return plugin_settings

pylsp_ruff/settings.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ class PluginSettings:
3333

3434
target_version: Optional[str] = None
3535

36+
virtual_documents_dir: Optional[str] = None
37+
3638

3739
def to_camel_case(snake_str: str) -> str:
3840
components = snake_str.split("_")

tests/conftest.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import tempfile
2+
from unittest.mock import Mock
3+
4+
import pytest
5+
from pylsp import uris
6+
from pylsp.config.config import Config
7+
from pylsp.workspace import Document, Workspace
8+
9+
10+
@pytest.fixture()
11+
def workspace(tmp_path):
12+
"""Return a workspace."""
13+
ws = Workspace(tmp_path.absolute().as_uri(), Mock())
14+
ws._config = Config(ws.root_uri, {}, 0, {})
15+
return ws
16+
17+
18+
@pytest.fixture()
19+
def notebook_workspace(tmp_path):
20+
"""Workspace with a notebook and `ruff.toml`.
21+
22+
Structure created under `tmp_path`:
23+
.
24+
├── .virtual_documents
25+
│ └── foo
26+
│ └── bar.ipynb
27+
└── foo
28+
├── bar.ipynb
29+
└── ruff.toml
30+
"""
31+
virtual_dir = tmp_path / ".virtual_documents" / "foo"
32+
real_dir = tmp_path / "foo"
33+
virtual_dir.mkdir(parents=True)
34+
real_dir.mkdir(parents=True)
35+
36+
(virtual_dir / "bar.ipynb").write_text("")
37+
(real_dir / "bar.ipynb").write_text("")
38+
(real_dir / "ruff.toml").write_text("")
39+
40+
ws = Workspace(tmp_path.absolute().as_uri(), Mock())
41+
ws._config = Config(ws.root_uri, {}, 0, {})
42+
return ws
43+
44+
45+
def temp_document(doc_text, workspace):
46+
with tempfile.NamedTemporaryFile(
47+
mode="w", dir=workspace.root_path, delete=False
48+
) as temp_file:
49+
name = temp_file.name
50+
temp_file.write(doc_text)
51+
doc = Document(uris.from_fs_path(name), workspace)
52+
return name, doc

tests/test_ruff_format.py

Lines changed: 26 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
import contextlib
2-
import tempfile
2+
import os
33
import textwrap as tw
44
from typing import Any, List, Mapping, Optional
5-
from unittest.mock import Mock
5+
from unittest.mock import patch
66

7-
import pytest
7+
from conftest import temp_document
88
from pylsp import uris
9-
from pylsp.config.config import Config
109
from pylsp.workspace import Document, Workspace
1110

1211
import pylsp_ruff.plugin as plugin
@@ -47,24 +46,6 @@ def bar():
4746
).strip()
4847

4948

50-
@pytest.fixture()
51-
def workspace(tmp_path):
52-
"""Return a workspace."""
53-
ws = Workspace(tmp_path.absolute().as_uri(), Mock())
54-
ws._config = Config(ws.root_uri, {}, 0, {})
55-
return ws
56-
57-
58-
def temp_document(doc_text, workspace):
59-
with tempfile.NamedTemporaryFile(
60-
mode="w", dir=workspace.root_path, delete=False
61-
) as temp_file:
62-
name = temp_file.name
63-
temp_file.write(doc_text)
64-
doc = Document(uris.from_fs_path(name), workspace)
65-
return name, doc
66-
67-
6849
def run_plugin_format(workspace: Workspace, doc: Document) -> str:
6950
class TestResult:
7051
result: Optional[List[Mapping[str, Any]]]
@@ -106,6 +87,29 @@ def test_ruff_format_disabled(workspace):
10687
assert got == ""
10788

10889

90+
def test_ruff_format_strips_virtual_documents_path(notebook_workspace):
91+
virtual_path = os.path.join(
92+
notebook_workspace.root_path, ".virtual_documents", "foo", "bar.ipynb"
93+
)
94+
expected_stripped = os.path.join(notebook_workspace.root_path, "foo", "bar.ipynb")
95+
doc_uri = uris.from_fs_path(virtual_path)
96+
notebook_workspace.put_document(doc_uri, _UNFORMATTED_CODE)
97+
doc = notebook_workspace.get_document(doc_uri)
98+
99+
with patch("pylsp_ruff.plugin.Popen") as popen_mock:
100+
mock_instance = popen_mock.return_value
101+
mock_instance.communicate.return_value = [bytes(), bytes()]
102+
run_plugin_format(notebook_workspace, doc)
103+
104+
format_calls = [
105+
call for call in popen_mock.call_args_list if "format" in call[0][0]
106+
]
107+
assert format_calls, "ruff format was not invoked"
108+
cmd = format_calls[0][0][0]
109+
assert f"--stdin-filename={expected_stripped}" in cmd
110+
assert f"--stdin-filename={virtual_path}" not in cmd
111+
112+
109113
def test_ruff_format_and_sort_imports(workspace):
110114
txt = f"{_UNSORTED_IMPORTS}\n{_UNFORMATTED_CODE}"
111115
want = f"{_SORTED_IMPORTS}\n\n\n{_FORMATTED_CODE}\n"

tests/test_ruff_lint.py

Lines changed: 35 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,11 @@
55
import stat
66
import sys
77
import tempfile
8-
from unittest.mock import Mock, patch
8+
from unittest.mock import patch
99

10-
import pytest
1110
from pylsp import lsp, uris
12-
from pylsp.config.config import Config
13-
from pylsp.workspace import Document, Workspace
11+
from pylsp.workspace import Document
12+
from conftest import temp_document
1413

1514
import pylsp_ruff.plugin as ruff_lint
1615

@@ -29,24 +28,6 @@ def using_const():
2928
"""
3029

3130

32-
@pytest.fixture()
33-
def workspace(tmp_path):
34-
"""Return a workspace."""
35-
ws = Workspace(tmp_path.absolute().as_uri(), Mock())
36-
ws._config = Config(ws.root_uri, {}, 0, {})
37-
return ws
38-
39-
40-
def temp_document(doc_text, workspace):
41-
with tempfile.NamedTemporaryFile(
42-
mode="w", dir=workspace.root_path, delete=False
43-
) as temp_file:
44-
name = temp_file.name
45-
temp_file.write(doc_text)
46-
doc = Document(uris.from_fs_path(name), workspace)
47-
return name, doc
48-
49-
5031
def test_ruff_unsaved(workspace):
5132
doc = Document("", workspace, DOC)
5233
diags = ruff_lint.pylsp_lint(workspace, doc)
@@ -276,6 +257,38 @@ def f():
276257
os.unlink(os.path.join(workspace.root_path, "pyproject.toml"))
277258

278259

260+
def test_strip_virtual_documents_path_no_match():
261+
path = "/work/foo/bar.py"
262+
assert ruff_lint.strip_virtual_documents_path(path, ".virtual_documents") == path
263+
264+
265+
def test_strip_virtual_documents_path_empty():
266+
path = "/work/.virtual_documents/foo/bar.ipynb"
267+
assert ruff_lint.strip_virtual_documents_path(path, None) == path
268+
assert ruff_lint.strip_virtual_documents_path(path, "") == path
269+
270+
271+
def test_ruff_lint_strips_virtual_documents_path(notebook_workspace):
272+
virtual_path = os.path.join(
273+
notebook_workspace.root_path, ".virtual_documents", "foo", "bar.ipynb"
274+
)
275+
expected_stripped = os.path.join(notebook_workspace.root_path, "foo", "bar.ipynb")
276+
doc_uri = uris.from_fs_path(virtual_path)
277+
notebook_workspace.put_document(doc_uri, "import os\n")
278+
doc = notebook_workspace.get_document(doc_uri)
279+
280+
with patch("pylsp_ruff.plugin.Popen") as popen_mock:
281+
mock_instance = popen_mock.return_value
282+
mock_instance.communicate.return_value = [bytes(), bytes()]
283+
ruff_lint.pylsp_lint(notebook_workspace, doc)
284+
285+
(call_args,) = popen_mock.call_args[0]
286+
assert f"--stdin-filename={expected_stripped}" in call_args
287+
assert (
288+
f"--stdin-filename={virtual_path}" not in call_args
289+
), "virtual_documents prefix was not stripped"
290+
291+
279292
def test_notebook_input(workspace):
280293
doc_str = r"""
281294
print('hi')

0 commit comments

Comments
 (0)