Skip to content

Commit 2757c3a

Browse files
authored
Add notebooks suppport to pylsp (#389)
1 parent 363d864 commit 2757c3a

File tree

8 files changed

+973
-7
lines changed

8 files changed

+973
-7
lines changed

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,24 @@ pip install 'python-lsp-server[websockets]'
161161
162162
## Development
163163
164+
Dev install
165+
166+
```
167+
# create conda env
168+
conda create --name python-lsp-server python=3.8 -y
169+
conda activate python-lsp-server
170+
171+
pip install ".[all]"
172+
pip install ".[websockets]"
173+
```
174+
175+
Run server with ws
176+
177+
```
178+
pylsp --ws -v # Info level logging
179+
pylsp --ws -v -v # Debug level logging
180+
```
181+
164182
To run the test suite:
165183
166184
```sh

pylsp/lsp.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,3 +90,8 @@ class TextDocumentSyncKind:
9090
NONE = 0
9191
FULL = 1
9292
INCREMENTAL = 2
93+
94+
95+
class NotebookCellKind:
96+
Markup = 1
97+
Code = 2

pylsp/python_lsp.py

Lines changed: 166 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
import os
77
import socketserver
88
import threading
9+
import uuid
10+
from typing import List, Dict, Any
911
import ujson as json
1012

1113
from pylsp_jsonrpc.dispatchers import MethodDispatcher
@@ -14,7 +16,7 @@
1416

1517
from . import lsp, _utils, uris
1618
from .config import config
17-
from .workspace import Workspace
19+
from .workspace import Workspace, Document, Notebook
1820
from ._version import __version__
1921

2022
log = logging.getLogger(__name__)
@@ -266,6 +268,11 @@ def capabilities(self):
266268
},
267269
'openClose': True,
268270
},
271+
'notebookDocumentSync': {
272+
'notebookSelector': {
273+
'cells': [{'language': 'python'}]
274+
}
275+
},
269276
'workspace': {
270277
'workspaceFolders': {
271278
'supported': True,
@@ -375,11 +382,79 @@ def hover(self, doc_uri, position):
375382
def lint(self, doc_uri, is_saved):
376383
# Since we're debounced, the document may no longer be open
377384
workspace = self._match_uri_to_workspace(doc_uri)
378-
if doc_uri in workspace.documents:
379-
workspace.publish_diagnostics(
380-
doc_uri,
381-
flatten(self._hook('pylsp_lint', doc_uri, is_saved=is_saved))
382-
)
385+
document_object = workspace.documents.get(doc_uri, None)
386+
if isinstance(document_object, Document):
387+
self._lint_text_document(doc_uri, workspace, is_saved=is_saved)
388+
elif isinstance(document_object, Notebook):
389+
self._lint_notebook_document(document_object, workspace)
390+
391+
def _lint_text_document(self, doc_uri, workspace, is_saved):
392+
workspace.publish_diagnostics(
393+
doc_uri,
394+
flatten(self._hook('pylsp_lint', doc_uri, is_saved=is_saved))
395+
)
396+
397+
def _lint_notebook_document(self, notebook_document, workspace): # pylint: disable=too-many-locals
398+
"""
399+
Lint a notebook document.
400+
401+
This is a bit more complicated than linting a text document, because we need to
402+
send the entire notebook document to the pylsp_lint hook, but we need to send
403+
the diagnostics back to the client on a per-cell basis.
404+
"""
405+
406+
# First, we create a temp TextDocument that represents the whole notebook
407+
# contents. We'll use this to send to the pylsp_lint hook.
408+
random_uri = str(uuid.uuid4())
409+
410+
# cell_list helps us map the diagnostics back to the correct cell later.
411+
cell_list: List[Dict[str, Any]] = []
412+
413+
offset = 0
414+
total_source = ""
415+
for cell in notebook_document.cells:
416+
cell_uri = cell['document']
417+
cell_document = workspace.get_cell_document(cell_uri)
418+
419+
num_lines = cell_document.line_count
420+
421+
data = {
422+
'uri': cell_uri,
423+
'line_start': offset,
424+
'line_end': offset + num_lines - 1,
425+
'source': cell_document.source
426+
}
427+
428+
cell_list.append(data)
429+
if offset == 0:
430+
total_source = cell_document.source
431+
else:
432+
total_source += ("\n" + cell_document.source)
433+
434+
offset += num_lines
435+
436+
workspace.put_document(random_uri, total_source)
437+
438+
try:
439+
document_diagnostics = flatten(self._hook('pylsp_lint', random_uri, is_saved=True))
440+
441+
# Now we need to map the diagnostics back to the correct cell and publish them.
442+
# Note: this is O(n*m) in the number of cells and diagnostics, respectively.
443+
for cell in cell_list:
444+
cell_diagnostics = []
445+
for diagnostic in document_diagnostics:
446+
start_line = diagnostic['range']['start']['line']
447+
end_line = diagnostic['range']['end']['line']
448+
449+
if start_line > cell['line_end'] or end_line < cell['line_start']:
450+
continue
451+
diagnostic['range']['start']['line'] = start_line - cell['line_start']
452+
diagnostic['range']['end']['line'] = end_line - cell['line_start']
453+
cell_diagnostics.append(diagnostic)
454+
455+
workspace.publish_diagnostics(cell['uri'], cell_diagnostics)
456+
finally:
457+
workspace.rm_document(random_uri)
383458

384459
def references(self, doc_uri, position, exclude_declaration):
385460
return flatten(self._hook(
@@ -399,6 +474,91 @@ def folding(self, doc_uri):
399474
def m_completion_item__resolve(self, **completionItem):
400475
return self.completion_item_resolve(completionItem)
401476

477+
def m_notebook_document__did_open(self, notebookDocument=None, cellTextDocuments=None, **_kwargs):
478+
workspace = self._match_uri_to_workspace(notebookDocument['uri'])
479+
workspace.put_notebook_document(
480+
notebookDocument['uri'],
481+
notebookDocument['notebookType'],
482+
cells=notebookDocument['cells'],
483+
version=notebookDocument.get('version'),
484+
metadata=notebookDocument.get('metadata')
485+
)
486+
for cell in (cellTextDocuments or []):
487+
workspace.put_cell_document(cell['uri'], cell['languageId'], cell['text'], version=cell.get('version'))
488+
self.lint(notebookDocument['uri'], is_saved=True)
489+
490+
def m_notebook_document__did_close(self, notebookDocument=None, cellTextDocuments=None, **_kwargs):
491+
workspace = self._match_uri_to_workspace(notebookDocument['uri'])
492+
for cell in (cellTextDocuments or []):
493+
workspace.publish_diagnostics(cell['uri'], [])
494+
workspace.rm_document(cell['uri'])
495+
workspace.rm_document(notebookDocument['uri'])
496+
497+
def m_notebook_document__did_change(self, notebookDocument=None, change=None, **_kwargs):
498+
"""
499+
Changes to the notebook document.
500+
501+
This could be one of the following:
502+
1. Notebook metadata changed
503+
2. Cell(s) added
504+
3. Cell(s) deleted
505+
4. Cell(s) data changed
506+
4.1 Cell metadata changed
507+
4.2 Cell source changed
508+
"""
509+
workspace = self._match_uri_to_workspace(notebookDocument['uri'])
510+
511+
if change.get('metadata'):
512+
# Case 1
513+
workspace.update_notebook_metadata(notebookDocument['uri'], change.get('metadata'))
514+
515+
cells = change.get('cells')
516+
if cells:
517+
# Change to cells
518+
structure = cells.get('structure')
519+
if structure:
520+
# Case 2 or 3
521+
notebook_cell_array_change = structure['array']
522+
start = notebook_cell_array_change['start']
523+
cell_delete_count = notebook_cell_array_change['deleteCount']
524+
if cell_delete_count == 0:
525+
# Case 2
526+
# Cell documents
527+
for cell_document in structure['didOpen']:
528+
workspace.put_cell_document(
529+
cell_document['uri'],
530+
cell_document['languageId'],
531+
cell_document['text'],
532+
cell_document.get('version')
533+
)
534+
# Cell metadata which is added to Notebook
535+
workspace.add_notebook_cells(notebookDocument['uri'], notebook_cell_array_change['cells'], start)
536+
else:
537+
# Case 3
538+
# Cell documents
539+
for cell_document in structure['didClose']:
540+
workspace.rm_document(cell_document['uri'])
541+
workspace.publish_diagnostics(cell_document['uri'], [])
542+
# Cell metadata which is removed from Notebook
543+
workspace.remove_notebook_cells(notebookDocument['uri'], start, cell_delete_count)
544+
545+
data = cells.get('data')
546+
if data:
547+
# Case 4.1
548+
for cell in data:
549+
# update NotebookDocument.cells properties
550+
pass
551+
552+
text_content = cells.get('textContent')
553+
if text_content:
554+
# Case 4.2
555+
for cell in text_content:
556+
cell_uri = cell['document']['uri']
557+
# Even though the protocol says that `changes` is an array, we assume that it's always a single
558+
# element array that contains the last change to the cell source.
559+
workspace.update_document(cell_uri, cell['changes'][0])
560+
self.lint(notebookDocument['uri'], is_saved=True)
561+
402562
def m_text_document__did_close(self, textDocument=None, **_kwargs):
403563
workspace = self._match_uri_to_workspace(textDocument['uri'])
404564
workspace.publish_diagnostics(textDocument['uri'], [])

pylsp/workspace.py

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import re
99
import uuid
1010
import functools
11-
from typing import Optional, Generator, Callable
11+
from typing import Optional, Generator, Callable, List
1212
from threading import RLock
1313

1414
import jedi
@@ -35,6 +35,8 @@ def wrapper(self, *args, **kwargs):
3535

3636
class Workspace:
3737

38+
# pylint: disable=too-many-public-methods
39+
3840
M_PUBLISH_DIAGNOSTICS = 'textDocument/publishDiagnostics'
3941
M_PROGRESS = '$/progress'
4042
M_INITIALIZE_PROGRESS = 'window/workDoneProgress/create'
@@ -105,12 +107,30 @@ def get_document(self, doc_uri):
105107
"""
106108
return self._docs.get(doc_uri) or self._create_document(doc_uri)
107109

110+
def get_cell_document(self, doc_uri):
111+
return self._docs.get(doc_uri)
112+
108113
def get_maybe_document(self, doc_uri):
109114
return self._docs.get(doc_uri)
110115

111116
def put_document(self, doc_uri, source, version=None):
112117
self._docs[doc_uri] = self._create_document(doc_uri, source=source, version=version)
113118

119+
def put_notebook_document(self, doc_uri, notebook_type, cells, version=None, metadata=None):
120+
self._docs[doc_uri] = self._create_notebook_document(doc_uri, notebook_type, cells, version, metadata)
121+
122+
def add_notebook_cells(self, doc_uri, cells, start):
123+
self._docs[doc_uri].add_cells(cells, start)
124+
125+
def remove_notebook_cells(self, doc_uri, start, delete_count):
126+
self._docs[doc_uri].remove_cells(start, delete_count)
127+
128+
def update_notebook_metadata(self, doc_uri, metadata):
129+
self._docs[doc_uri].metadata = metadata
130+
131+
def put_cell_document(self, doc_uri, language_id, source, version=None):
132+
self._docs[doc_uri] = self._create_cell_document(doc_uri, language_id, source, version)
133+
114134
def rm_document(self, doc_uri):
115135
self._docs.pop(doc_uri)
116136

@@ -275,6 +295,29 @@ def _create_document(self, doc_uri, source=None, version=None):
275295
rope_project_builder=self._rope_project_builder,
276296
)
277297

298+
def _create_notebook_document(self, doc_uri, notebook_type, cells, version=None, metadata=None):
299+
return Notebook(
300+
doc_uri,
301+
notebook_type,
302+
self,
303+
cells=cells,
304+
version=version,
305+
metadata=metadata
306+
)
307+
308+
def _create_cell_document(self, doc_uri, language_id, source=None, version=None):
309+
# TODO: remove what is unnecessary here.
310+
path = uris.to_fs_path(doc_uri)
311+
return Cell(
312+
doc_uri,
313+
language_id=language_id,
314+
workspace=self,
315+
source=source,
316+
version=version,
317+
extra_sys_path=self.source_roots(path),
318+
rope_project_builder=self._rope_project_builder,
319+
)
320+
278321
def close(self):
279322
if self.__rope_autoimport is not None:
280323
self.__rope_autoimport.close()
@@ -463,3 +506,45 @@ def sys_path(self, environment_path=None, env_vars=None):
463506
environment = self.get_enviroment(environment_path=environment_path, env_vars=env_vars)
464507
path.extend(environment.get_sys_path())
465508
return path
509+
510+
511+
class Notebook:
512+
"""Represents a notebook."""
513+
def __init__(self, uri, notebook_type, workspace, cells=None, version=None, metadata=None):
514+
self.uri = uri
515+
self.notebook_type = notebook_type
516+
self.workspace = workspace
517+
self.version = version
518+
self.cells = cells or []
519+
self.metadata = metadata or {}
520+
521+
def __str__(self):
522+
return "Notebook with URI '%s'" % str(self.uri)
523+
524+
def add_cells(self, new_cells: List, start: int) -> None:
525+
self.cells[start:start] = new_cells
526+
527+
def remove_cells(self, start: int, delete_count: int) -> None:
528+
del self.cells[start:start+delete_count]
529+
530+
531+
class Cell(Document):
532+
"""
533+
Represents a cell in a notebook.
534+
535+
Notes
536+
-----
537+
We inherit from Document for now to get the same API. However, a cell document differs from text documents in that
538+
they have a language id.
539+
"""
540+
541+
def __init__(self, uri, language_id, workspace, source=None, version=None, local=True, extra_sys_path=None,
542+
rope_project_builder=None):
543+
super().__init__(uri, workspace, source, version, local, extra_sys_path, rope_project_builder)
544+
self.language_id = language_id
545+
546+
@property
547+
@lock
548+
def line_count(self):
549+
""""Return the number of lines in the cell document."""
550+
return len(self.source.split('\n'))

0 commit comments

Comments
 (0)