Skip to content

Commit a49b5bc

Browse files
authored
Merge pull request #930 from krassowski/optional-virtual-documents-on-disk
Make `.virtual_documents` optional
2 parents 661b743 + b4136fb commit a49b5bc

File tree

8 files changed

+137
-34
lines changed

8 files changed

+137
-34
lines changed

docs/Configuring.ipynb

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,9 @@
8080
" matching the `contentsModel` against the registered file types using\n",
8181
" `getFileTypeForModel()` method (if multiple MIME types are present, the\n",
8282
" first one will be used).\n",
83+
"- `requires_documents_on_disk` should be `false` for all new specifications, as\n",
84+
" any code paths requiring documents on disks should be fixed in the LSP servers\n",
85+
" rather than masked by using the `.virtual_documents` workaround.\n",
8386
"\n",
8487
"```python\n",
8588
"# ./jupyter_server_config.json ---------- unique! -----------\n",
@@ -211,10 +214,13 @@
211214
"\n",
212215
"> default: os.getenv(\"JP_LSP_VIRTUAL_DIR\", \".virtual_documents\")\n",
213216
"\n",
214-
"Path to virtual documents relative to the content manager root directory.\n",
217+
"Path (relative to the content manager root directory) where a transient copy of\n",
218+
"the virtual documents should be written allowing LSP servers to access the file\n",
219+
"using operating system's file system APIs if they need so (which is\n",
220+
"discouraged).\n",
215221
"\n",
216-
"Its default value can be set with `JP_LSP_VIRTUAL_DIR` environment variable and\n",
217-
"fallback to `.virtual_documents`."
222+
"Its default value can be set with `JP_LSP_VIRTUAL_DIR` environment variable. The\n",
223+
"fallback value is `.virtual_documents`."
218224
]
219225
},
220226
{
@@ -340,7 +346,7 @@
340346
"name": "python",
341347
"nbconvert_exporter": "python",
342348
"pygments_lexer": "ipython3",
343-
"version": "3.7.9"
349+
"version": "3.8.6"
344350
},
345351
"widgets": {
346352
"application/vnd.jupyter.widget-state+json": {

packages/jupyterlab-lsp/src/connection_manager.ts

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -391,27 +391,38 @@ export namespace DocumentConnectionManager {
391391
virtual_document: VirtualDocument,
392392
language: string
393393
): IURIs {
394-
const { settings } = Private.getLanguageServerManager();
395-
const wsBase = settings.wsUrl;
394+
const serverManager = Private.getLanguageServerManager();
395+
const wsBase = serverManager.settings.wsUrl;
396396
const rootUri = PageConfig.getOption('rootUri');
397397
const virtualDocumentsUri = PageConfig.getOption('virtualDocumentsUri');
398398

399-
const baseUri = virtual_document.has_lsp_supported_file
400-
? rootUri
401-
: virtualDocumentsUri;
402-
403399
// for now take the best match only
404-
const matchingServers =
405-
Private.getLanguageServerManager().getMatchingServers({
406-
language
407-
});
408-
const language_server_id =
400+
const serverOptions: ILanguageServerManager.IGetServerIdOptions = {
401+
language
402+
};
403+
const matchingServers = serverManager.getMatchingServers(serverOptions);
404+
const languageServerId =
409405
matchingServers.length === 0 ? null : matchingServers[0];
410406

411-
if (language_server_id === null) {
407+
if (languageServerId === null) {
412408
throw `No language server installed for language ${language}`;
413409
}
414410

411+
const specs = serverManager.getMatchingSpecs(serverOptions);
412+
const spec = specs.get(languageServerId);
413+
if (!spec) {
414+
console.warn(
415+
`Specification not available for server ${languageServerId}`
416+
);
417+
}
418+
const requiresOnDiskFiles = spec?.requires_documents_on_disk ?? true;
419+
const supportsInMemoryFiles = !requiresOnDiskFiles;
420+
421+
const baseUri =
422+
virtual_document.has_lsp_supported_file || supportsInMemoryFiles
423+
? rootUri
424+
: virtualDocumentsUri;
425+
415426
// workaround url-parse bug(s) (see https://github.com/jupyter-lsp/jupyterlab-lsp/issues/595)
416427
let documentUri = URLExt.join(baseUri, virtual_document.uri);
417428
if (
@@ -435,7 +446,7 @@ export namespace DocumentConnectionManager {
435446
wsBase,
436447
ILanguageServerManager.URL_NS,
437448
'ws',
438-
language_server_id
449+
languageServerId
439450
)
440451
};
441452
}

python_packages/jupyter_lsp/jupyter_lsp/schema/schema.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,11 @@
126126
"title": "Extensions",
127127
"type": "array"
128128
},
129+
"requires_documents_on_disk": {
130+
"default": true,
131+
"description": "Whether to write un-saved documents to disk in a transient `.virtual_documents` directory. Well-behaved language servers that work against in-memory files should set this to `false`, which will become the default in the future.",
132+
"type": "boolean"
133+
},
129134
"install": {
130135
"$ref": "#/definitions/install-bundle",
131136
"description": "a list of installation approaches keyed by package manager, e.g. pip, npm, yarn, apt",

python_packages/jupyter_lsp/jupyter_lsp/serverextension.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,28 @@ async def initialize(nbapp, virtual_documents_uri): # pragma: no cover
1616

1717
from .virtual_documents_shadow import setup_shadow_filesystem
1818

19-
manager = nbapp.language_server_manager
19+
manager: LanguageServerManager = nbapp.language_server_manager
2020

2121
with concurrent.futures.ThreadPoolExecutor() as pool:
2222
await nbapp.io_loop.run_in_executor(pool, manager.initialize)
2323

24-
setup_shadow_filesystem(virtual_documents_uri=virtual_documents_uri)
24+
servers_requiring_disk_access = [
25+
server_id
26+
for server_id, server in manager.language_servers.items()
27+
if server.get("requires_documents_on_disk", True)
28+
]
29+
30+
if any(servers_requiring_disk_access):
31+
nbapp.log.debug(
32+
"[lsp] Servers that requested virtual documents on disk: %s",
33+
servers_requiring_disk_access,
34+
)
35+
setup_shadow_filesystem(virtual_documents_uri=virtual_documents_uri)
36+
else:
37+
nbapp.log.debug(
38+
"[lsp] None of the installed servers require virtual documents"
39+
" disabling shadow filesystem."
40+
)
2541

2642
nbapp.log.debug(
2743
"[lsp] The following Language Servers will be available: {}".format(

python_packages/jupyter_lsp/jupyter_lsp/specs/pyright.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,5 @@ class PyrightLanguageServer(NodeModuleSpec):
2020
jlpm="jlpm add --dev {}".format(key),
2121
),
2222
config_schema=load_config_schema(key),
23+
requires_documents_on_disk=False,
2324
)

python_packages/jupyter_lsp/jupyter_lsp/tests/test_virtual_documents_shadow.py

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from pathlib import Path
2+
from types import SimpleNamespace
23
from typing import List
34

45
import pytest
@@ -90,6 +91,13 @@ def shadow_path(tmpdir):
9091
return str(tmpdir.mkdir(".virtual_documents"))
9192

9293

94+
@pytest.fixture
95+
def manager():
96+
return SimpleNamespace(
97+
language_servers={"python-lsp-server": {"requires_documents_on_disk": True}}
98+
)
99+
100+
93101
@pytest.mark.asyncio
94102
@pytest.mark.parametrize(
95103
"message_func, content, expected_content",
@@ -99,26 +107,66 @@ def shadow_path(tmpdir):
99107
[did_save_with_text, "content at save", "content at save"],
100108
],
101109
)
102-
async def test_shadow(shadow_path, message_func, content, expected_content):
110+
async def test_shadow(shadow_path, message_func, content, expected_content, manager):
103111
shadow = setup_shadow_filesystem(Path(shadow_path).as_uri())
104112
ok_file_path = Path(shadow_path) / "test.py"
105113

106114
message = message_func(ok_file_path.as_uri(), content)
107-
result = await shadow("client", message, ["python"], None)
115+
result = await shadow("client", message, "python-lsp-server", manager)
108116
assert isinstance(result, str)
109117

110-
with open(str(ok_file_path)) as f: # str is a Python 3.5 relict
118+
with open(ok_file_path) as f:
111119
assert f.read() == expected_content
112120

113121

114122
@pytest.mark.asyncio
115-
async def test_shadow_failures(shadow_path):
123+
async def test_no_shadow_for_well_behaved_server(
124+
shadow_path,
125+
):
126+
"""We call server well behaved when it does not require a disk copy"""
127+
shadow_path_for_well = Path(shadow_path) / "well"
128+
shadow = setup_shadow_filesystem(Path(shadow_path_for_well).as_uri())
129+
ok_file_path = Path(shadow_path_for_well) / "test.py"
130+
131+
manager = SimpleNamespace(
132+
language_servers={"python-lsp-server": {"requires_documents_on_disk": False}}
133+
)
134+
135+
message = did_open(ok_file_path.as_uri(), "content\nof\nopened\nfile")
136+
result = await shadow("client", message, "python-lsp-server", manager)
137+
# should short-circuit for well behaved server
138+
assert result is None
139+
# should not create the directory
140+
assert not shadow_path_for_well.exists()
141+
142+
143+
@pytest.mark.asyncio
144+
async def test_shadow_created_for_ill_behaved_server(
145+
shadow_path,
146+
):
147+
shadow_path_for_ill = Path(shadow_path) / "ill"
148+
shadow = setup_shadow_filesystem(shadow_path_for_ill.as_uri())
149+
ok_file_path = Path(shadow_path_for_ill) / "test.py"
150+
151+
manager = SimpleNamespace(
152+
language_servers={"python-lsp-server": {"requires_documents_on_disk": True}}
153+
)
116154

155+
message = did_open(ok_file_path.as_uri(), "content\nof\nopened\nfile")
156+
result = await shadow("client", message, "python-lsp-server", manager)
157+
assert result is not None
158+
# should create the directory at given path
159+
assert shadow_path_for_ill.exists()
160+
assert shadow_path_for_ill.is_dir()
161+
162+
163+
@pytest.mark.asyncio
164+
async def test_shadow_failures(shadow_path, manager):
117165
shadow = setup_shadow_filesystem(Path(shadow_path).as_uri())
118166
ok_file_uri = (Path(shadow_path) / "test.py").as_uri()
119167

120168
def run_shadow(message):
121-
return shadow("client", message, ["python"], None)
169+
return shadow("client", message, "python-lsp-server", manager)
122170

123171
# missing textDocument
124172
with pytest.raises(ShadowFilesystemError, match="Could not get textDocument from"):

python_packages/jupyter_lsp/jupyter_lsp/types.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,14 @@
2020
Pattern,
2121
Text,
2222
Union,
23+
cast,
2324
)
2425

2526
try:
2627
from jupyter_server.transutils import _i18n as _
2728
except ImportError: # pragma: no cover
2829
from jupyter_server.transutils import _
30+
2931
from traitlets import Instance
3032
from traitlets import List as List_
3133
from traitlets import Unicode, default
@@ -44,7 +46,7 @@ def __call__(
4446
scope: Text,
4547
message: LanguageServerMessage,
4648
language_server: Text,
47-
manager: "HasListeners",
49+
manager: "LanguageServerManagerAPI",
4850
) -> Awaitable[None]:
4951
...
5052

@@ -89,7 +91,7 @@ async def __call__(
8991
scope: Text,
9092
message: LanguageServerMessage,
9193
language_server: Text,
92-
manager: "HasListeners",
94+
manager: "LanguageServerManagerAPI",
9395
) -> None:
9496
"""actually dispatch the message to the listener and capture any errors"""
9597
try:
@@ -182,7 +184,7 @@ async def wait_for_listeners(
182184
scope_val,
183185
message=message,
184186
language_server=language_server,
185-
manager=self,
187+
manager=cast("LanguageServerManagerAPI", self),
186188
)
187189
for listener in listeners
188190
if listener.wants(message, language_server)
@@ -195,6 +197,8 @@ async def wait_for_listeners(
195197
class LanguageServerManagerAPI(LoggingConfigurable, HasListeners):
196198
"""Public API that can be used for python-based spec finders and listeners"""
197199

200+
language_servers: KeyedLanguageServerSpecs
201+
198202
nodejs = Unicode(help=_("path to nodejs executable")).tag(config=True)
199203

200204
node_roots = List_([], help=_("absolute paths in which to seek node_modules")).tag(

python_packages/jupyter_lsp/jupyter_lsp/virtual_documents_shadow.py

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from .manager import lsp_message_listener
1010
from .paths import file_uri_to_path
11+
from .types import LanguageServerManagerAPI
1112

1213
# TODO: make configurable
1314
MAX_WORKERS = 4
@@ -101,21 +102,16 @@ class ShadowFilesystemError(ValueError):
101102
"""Error in the shadow file system."""
102103

103104

104-
def setup_shadow_filesystem(virtual_documents_uri):
105+
def setup_shadow_filesystem(virtual_documents_uri: str):
105106

106107
if not virtual_documents_uri.startswith("file:/"):
107108
raise ShadowFilesystemError( # pragma: no cover
108109
'Virtual documents URI has to start with "file:/", got '
109110
+ virtual_documents_uri
110111
)
111112

113+
initialized = False
112114
shadow_filesystem = Path(file_uri_to_path(virtual_documents_uri))
113-
# create if does no exist (so that removal does not raise)
114-
shadow_filesystem.mkdir(parents=True, exist_ok=True)
115-
# remove with contents
116-
rmtree(str(shadow_filesystem))
117-
# create again
118-
shadow_filesystem.mkdir(parents=True, exist_ok=True)
119115

120116
@lsp_message_listener("client")
121117
async def shadow_virtual_documents(scope, message, language_server, manager):
@@ -124,6 +120,12 @@ async def shadow_virtual_documents(scope, message, language_server, manager):
124120
Only create the shadow file if the URI matches the virtual documents URI.
125121
Returns the path on filesystem where the content was stored.
126122
"""
123+
nonlocal initialized
124+
125+
# short-circut if language server does not require documents on disk
126+
server_spec = manager.language_servers[language_server]
127+
if not server_spec.get("requires_documents_on_disk", True):
128+
return
127129

128130
if not message.get("method") in WRITE_ONE:
129131
return
@@ -141,6 +143,16 @@ async def shadow_virtual_documents(scope, message, language_server, manager):
141143
if not uri.startswith(virtual_documents_uri):
142144
return
143145

146+
# initialization (/any file system operations) delayed until needed
147+
if not initialized:
148+
# create if does no exist (so that removal does not raise)
149+
shadow_filesystem.mkdir(parents=True, exist_ok=True)
150+
# remove with contents
151+
rmtree(str(shadow_filesystem))
152+
# create again
153+
shadow_filesystem.mkdir(parents=True, exist_ok=True)
154+
initialized = True
155+
144156
path = file_uri_to_path(uri)
145157
editable_file = EditableFile(path)
146158

0 commit comments

Comments
 (0)