Skip to content

Commit 771fed0

Browse files
authored
Interactive window middleware for Pylance LSP notebooks (#19501)
1 parent cb9ca13 commit 771fed0

8 files changed

+499
-7
lines changed

src/client/activation/languageClientMiddlewareBase.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,29 @@ export class LanguageClientMiddlewareBase implements Middleware {
171171
return this.callNext('willSaveWaitUntil', arguments);
172172
}
173173

174+
public async didOpenNotebook() {
175+
return this.callNotebooksNext('didOpen', arguments);
176+
}
177+
178+
public async didSaveNotebook() {
179+
return this.callNotebooksNext('didSave', arguments);
180+
}
181+
182+
public async didChangeNotebook() {
183+
return this.callNotebooksNext('didChange', arguments);
184+
}
185+
186+
public async didCloseNotebook() {
187+
return this.callNotebooksNext('didClose', arguments);
188+
}
189+
190+
notebooks = {
191+
didOpen: this.didOpenNotebook.bind(this),
192+
didSave: this.didSaveNotebook.bind(this),
193+
didChange: this.didChangeNotebook.bind(this),
194+
didClose: this.didCloseNotebook.bind(this),
195+
};
196+
174197
public async provideCompletionItem() {
175198
if (await this.connected) {
176199
return this.callNextAndSendTelemetry(
@@ -463,6 +486,17 @@ export class LanguageClientMiddlewareBase implements Middleware {
463486
return args[args.length - 1](...args);
464487
}
465488

489+
private callNotebooksNext(funcName: 'didOpen' | 'didSave' | 'didChange' | 'didClose', args: IArguments) {
490+
// This function uses the last argument to call the 'next' item. If we're allowing notebook
491+
// middleware, it calls into the notebook middleware first.
492+
if (this.notebookAddon?.notebooks && (this.notebookAddon.notebooks as any)[funcName]) {
493+
// It would be nice to use args.callee, but not supported in strict mode
494+
return (this.notebookAddon.notebooks as any)[funcName](...args);
495+
}
496+
497+
return args[args.length - 1](...args);
498+
}
499+
466500
private callNextAndSendTelemetry<T extends keyof MiddleWareMethods>(
467501
lspMethod: string,
468502
debounceMilliseconds: number,

src/client/activation/node/analysisOptions.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export class NodeLanguageServerAnalysisOptions extends LanguageServerAnalysisOpt
2424
experimentationSupport: true,
2525
trustedWorkspaceSupport: true,
2626
lspNotebooksSupport: this.lspNotebooksExperiment.isInNotebooksExperiment(),
27+
lspInteractiveWindowSupport: this.lspNotebooksExperiment.isInNotebooksExperimentWithInteractiveWindowSupport(),
2728
} as unknown) as LanguageClientOptions;
2829
}
2930
}

src/client/activation/node/languageClientMiddleware.ts

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22
// Licensed under the MIT License.
33

44
import { Uri } from 'vscode';
5+
import { LanguageClient } from 'vscode-languageclient/node';
56
import { IJupyterExtensionDependencyManager } from '../../common/application/types';
67
import { IServiceContainer } from '../../ioc/types';
78
import { JupyterExtensionIntegration } from '../../jupyter/jupyterIntegration';
89
import { traceLog } from '../../logging';
910
import { LanguageClientMiddleware } from '../languageClientMiddleware';
11+
import { LspInteractiveWindowMiddlewareAddon } from './lspInteractiveWindowMiddlewareAddon';
1012

1113
import { LanguageServerType } from '../types';
1214

@@ -15,11 +17,27 @@ import { LspNotebooksExperiment } from './lspNotebooksExperiment';
1517
export class NodeLanguageClientMiddleware extends LanguageClientMiddleware {
1618
private readonly lspNotebooksExperiment: LspNotebooksExperiment;
1719

18-
public constructor(serviceContainer: IServiceContainer, serverVersion?: string) {
20+
private readonly jupyterExtensionIntegration: JupyterExtensionIntegration;
21+
22+
public constructor(
23+
serviceContainer: IServiceContainer,
24+
private getClient: () => LanguageClient | undefined,
25+
serverVersion?: string,
26+
) {
1927
super(serviceContainer, LanguageServerType.Node, serverVersion);
2028

2129
this.lspNotebooksExperiment = serviceContainer.get<LspNotebooksExperiment>(LspNotebooksExperiment);
2230
this.setupHidingMiddleware(serviceContainer);
31+
32+
this.jupyterExtensionIntegration = serviceContainer.get<JupyterExtensionIntegration>(
33+
JupyterExtensionIntegration,
34+
);
35+
if (!this.notebookAddon && this.lspNotebooksExperiment.isInNotebooksExperimentWithInteractiveWindowSupport()) {
36+
this.notebookAddon = new LspInteractiveWindowMiddlewareAddon(
37+
this.getClient,
38+
this.jupyterExtensionIntegration,
39+
);
40+
}
2341
}
2442

2543
protected shouldCreateHidingMiddleware(jupyterDependencyManager: IJupyterExtensionDependencyManager): boolean {
@@ -34,18 +52,24 @@ export class NodeLanguageClientMiddleware extends LanguageClientMiddleware {
3452
await this.lspNotebooksExperiment.onJupyterInstalled();
3553
}
3654

37-
super.onExtensionChange(jupyterDependencyManager);
55+
if (this.lspNotebooksExperiment.isInNotebooksExperimentWithInteractiveWindowSupport()) {
56+
if (!this.notebookAddon) {
57+
this.notebookAddon = new LspInteractiveWindowMiddlewareAddon(
58+
this.getClient,
59+
this.jupyterExtensionIntegration,
60+
);
61+
}
62+
} else {
63+
super.onExtensionChange(jupyterDependencyManager);
64+
}
3865
}
3966

4067
protected async getPythonPathOverride(uri: Uri | undefined): Promise<string | undefined> {
4168
if (!uri || !this.lspNotebooksExperiment.isInNotebooksExperiment()) {
4269
return undefined;
4370
}
4471

45-
const jupyterExtensionIntegration = this.serviceContainer?.get<JupyterExtensionIntegration>(
46-
JupyterExtensionIntegration,
47-
);
48-
const jupyterPythonPathFunction = jupyterExtensionIntegration?.getJupyterPythonPathFunction();
72+
const jupyterPythonPathFunction = this.jupyterExtensionIntegration.getJupyterPythonPathFunction();
4973
if (!jupyterPythonPathFunction) {
5074
return undefined;
5175
}
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
import { Disposable, NotebookCell, NotebookDocument, TextDocument, TextDocumentChangeEvent, Uri } from 'vscode';
4+
import { Converter } from 'vscode-languageclient/lib/common/codeConverter';
5+
import {
6+
DidChangeNotebookDocumentNotification,
7+
LanguageClient,
8+
Middleware,
9+
NotebookCellKind,
10+
NotebookDocumentChangeEvent,
11+
} from 'vscode-languageclient/node';
12+
import * as proto from 'vscode-languageserver-protocol';
13+
import { JupyterExtensionIntegration } from '../../jupyter/jupyterIntegration';
14+
15+
type TextContent = Required<Required<Required<proto.NotebookDocumentChangeEvent>['cells']>['textContent']>[0];
16+
17+
/**
18+
* Detects the input box text documents of Interactive Windows and makes them appear to be
19+
* the last cell of their corresponding notebooks.
20+
*/
21+
export class LspInteractiveWindowMiddlewareAddon implements Middleware, Disposable {
22+
constructor(
23+
private readonly getClient: () => LanguageClient | undefined,
24+
private readonly jupyterExtensionIntegration: JupyterExtensionIntegration,
25+
) {
26+
// Make sure a bunch of functions are bound to this. VS code can call them without a this context
27+
this.didOpen = this.didOpen.bind(this);
28+
this.didChange = this.didChange.bind(this);
29+
this.didClose = this.didClose.bind(this);
30+
}
31+
32+
public dispose(): void {
33+
// Nothing to dispose at the moment
34+
}
35+
36+
// Map of document URIs to NotebookDocuments for all known notebooks.
37+
private notebookDocumentMap: Map<string, NotebookDocument> = new Map<string, NotebookDocument>();
38+
39+
// Map of document URIs to TextDocuments that should be linked to a notebook
40+
// whose didOpen we're expecting to see in the future.
41+
private unlinkedTextDocumentMap: Map<string, TextDocument> = new Map<string, TextDocument>();
42+
43+
public async didOpen(document: TextDocument, next: (ev: TextDocument) => Promise<void>): Promise<void> {
44+
const notebookUri = this.getNotebookUriForTextDocumentUri(document.uri);
45+
if (!notebookUri) {
46+
await next(document);
47+
return;
48+
}
49+
50+
const notebookDocument = this.notebookDocumentMap.get(notebookUri.toString());
51+
if (!notebookDocument) {
52+
this.unlinkedTextDocumentMap.set(notebookUri.toString(), document);
53+
return;
54+
}
55+
56+
try {
57+
const result: NotebookDocumentChangeEvent = {
58+
cells: {
59+
structure: {
60+
array: {
61+
start: notebookDocument.cellCount,
62+
deleteCount: 0,
63+
cells: [{ kind: NotebookCellKind.Code, document: document.uri.toString() }],
64+
},
65+
didOpen: [
66+
{
67+
uri: document.uri.toString(),
68+
languageId: document.languageId,
69+
version: document.version,
70+
text: document.getText(),
71+
},
72+
],
73+
didClose: undefined,
74+
},
75+
},
76+
};
77+
78+
await this.getClient()?.sendNotification(DidChangeNotebookDocumentNotification.type, {
79+
notebookDocument: { version: notebookDocument.version, uri: notebookUri.toString() },
80+
change: result,
81+
});
82+
} catch (error) {
83+
this.getClient()?.error('Sending DidChangeNotebookDocumentNotification failed', error);
84+
throw error;
85+
}
86+
}
87+
88+
public async didChange(
89+
event: TextDocumentChangeEvent,
90+
next: (ev: TextDocumentChangeEvent) => Promise<void>,
91+
): Promise<void> {
92+
const notebookUri = this.getNotebookUriForTextDocumentUri(event.document.uri);
93+
if (!notebookUri) {
94+
await next(event);
95+
return;
96+
}
97+
98+
const notebookDocument = this.notebookDocumentMap.get(notebookUri.toString());
99+
if (notebookDocument) {
100+
const client = this.getClient();
101+
if (client) {
102+
client.sendNotification(proto.DidChangeNotebookDocumentNotification.type, {
103+
notebookDocument: { uri: notebookUri.toString(), version: notebookDocument.version },
104+
change: {
105+
cells: {
106+
textContent: [
107+
LspInteractiveWindowMiddlewareAddon._asTextContentChange(
108+
event,
109+
client.code2ProtocolConverter,
110+
),
111+
],
112+
},
113+
},
114+
});
115+
}
116+
}
117+
}
118+
119+
private static _asTextContentChange(event: TextDocumentChangeEvent, c2pConverter: Converter): TextContent {
120+
const params = c2pConverter.asChangeTextDocumentParams(event);
121+
return { document: params.textDocument, changes: params.contentChanges };
122+
}
123+
124+
public async didClose(document: TextDocument, next: (ev: TextDocument) => Promise<void>): Promise<void> {
125+
const notebookUri = this.getNotebookUriForTextDocumentUri(document.uri);
126+
if (!notebookUri) {
127+
await next(document);
128+
return;
129+
}
130+
131+
this.unlinkedTextDocumentMap.delete(notebookUri.toString());
132+
}
133+
134+
public async didOpenNotebook(
135+
notebookDocument: NotebookDocument,
136+
cells: NotebookCell[],
137+
next: (notebookDocument: NotebookDocument, cells: NotebookCell[]) => Promise<void>,
138+
): Promise<void> {
139+
this.notebookDocumentMap.set(notebookDocument.uri.toString(), notebookDocument);
140+
141+
const relatedTextDocument = this.unlinkedTextDocumentMap.get(notebookDocument.uri.toString());
142+
if (relatedTextDocument) {
143+
const newCells = [
144+
...cells,
145+
{
146+
index: notebookDocument.cellCount,
147+
notebook: notebookDocument,
148+
kind: NotebookCellKind.Code,
149+
document: relatedTextDocument,
150+
metadata: {},
151+
outputs: [],
152+
executionSummary: undefined,
153+
},
154+
];
155+
156+
this.unlinkedTextDocumentMap.delete(notebookDocument.uri.toString());
157+
158+
await next(notebookDocument, newCells);
159+
} else {
160+
await next(notebookDocument, cells);
161+
}
162+
}
163+
164+
public async didCloseNotebook(
165+
notebookDocument: NotebookDocument,
166+
cells: NotebookCell[],
167+
next: (notebookDocument: NotebookDocument, cells: NotebookCell[]) => Promise<void>,
168+
): Promise<void> {
169+
this.notebookDocumentMap.delete(notebookDocument.uri.toString());
170+
171+
await next(notebookDocument, cells);
172+
}
173+
174+
notebooks = {
175+
didOpen: this.didOpenNotebook.bind(this),
176+
didClose: this.didCloseNotebook.bind(this),
177+
};
178+
179+
private getNotebookUriForTextDocumentUri(textDocumentUri: Uri): Uri | undefined {
180+
const getNotebookUriFunction = this.jupyterExtensionIntegration.getGetNotebookUriForTextDocumentUriFunction();
181+
if (!getNotebookUriFunction) {
182+
return undefined;
183+
}
184+
185+
return getNotebookUriFunction(textDocumentUri);
186+
}
187+
}

src/client/activation/node/lspNotebooksExperiment.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ export class LspNotebooksExperiment implements IExtensionSingleActivationService
2525

2626
private isInExperiment: boolean | undefined;
2727

28+
private supportsInteractiveWindow: boolean | undefined;
29+
2830
constructor(
2931
@inject(IServiceContainer) private readonly serviceContainer: IServiceContainer,
3032
@inject(IConfigurationService) private readonly configurationService: IConfigurationService,
@@ -60,6 +62,10 @@ export class LspNotebooksExperiment implements IExtensionSingleActivationService
6062
return this.isInExperiment ?? false;
6163
}
6264

65+
public isInNotebooksExperimentWithInteractiveWindowSupport(): boolean {
66+
return this.supportsInteractiveWindow ?? false;
67+
}
68+
6369
private updateExperimentSupport(): void {
6470
const wasInExperiment = this.isInExperiment;
6571
const isInTreatmentGroup = this.configurationService.getSettings().pylanceLspNotebooksEnabled;
@@ -87,6 +93,18 @@ export class LspNotebooksExperiment implements IExtensionSingleActivationService
8793
sendTelemetryEvent(EventName.PYTHON_EXPERIMENTS_LSP_NOTEBOOKS);
8894
}
8995

96+
this.supportsInteractiveWindow = false;
97+
if (!this.isInExperiment) {
98+
traceLog(`LSP Notebooks interactive window support is disabled -- not in LSP Notebooks experiment`);
99+
} else if (!LspNotebooksExperiment.jupyterSupportsLspInteractiveWindow()) {
100+
traceLog(`LSP Notebooks interactive window support is disabled -- Jupyter is not new enough`);
101+
} else if (!LspNotebooksExperiment.pylanceSupportsLspInteractiveWindow()) {
102+
traceLog(`LSP Notebooks interactive window support is disabled -- Pylance is not new enough`);
103+
} else {
104+
this.supportsInteractiveWindow = true;
105+
traceLog(`LSP Notebooks interactive window support is enabled`);
106+
}
107+
90108
// Our "in experiment" status can only change from false to true. That's possible if Pylance
91109
// or Jupyter is installed after Python is activated. A true to false transition would require
92110
// either Pylance or Jupyter to be uninstalled or downgraded after Python activated, and that
@@ -114,6 +132,21 @@ export class LspNotebooksExperiment implements IExtensionSingleActivationService
114132
);
115133
}
116134

135+
private static jupyterSupportsLspInteractiveWindow(): boolean {
136+
const jupyterVersion = extensions.getExtension(JUPYTER_EXTENSION_ID)?.packageJSON.version;
137+
return (
138+
jupyterVersion && (semver.gt(jupyterVersion, '2022.7.1002041057') || semver.patch(jupyterVersion) === 100)
139+
);
140+
}
141+
142+
private static pylanceSupportsLspInteractiveWindow(): boolean {
143+
const pylanceVersion = extensions.getExtension(PYLANCE_EXTENSION_ID)?.packageJSON.version;
144+
return (
145+
pylanceVersion &&
146+
(semver.gte(pylanceVersion, '2022.7.51') || semver.prerelease(pylanceVersion)?.includes('dev'))
147+
);
148+
}
149+
117150
private async waitForJupyterToRegisterPythonPathFunction(): Promise<void> {
118151
const jupyterExtensionIntegration = this.serviceContainer.get<JupyterExtensionIntegration>(
119152
JupyterExtensionIntegration,

src/client/activation/node/manager.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,11 @@ export class NodeLanguageServerManager implements ILanguageServerManager {
117117
@traceDecoratorVerbose('Starting language server')
118118
protected async startLanguageServer(): Promise<void> {
119119
const options = await this.analysisOptions.getAnalysisOptions();
120-
this.middleware = new NodeLanguageClientMiddleware(this.serviceContainer, this.lsVersion);
120+
this.middleware = new NodeLanguageClientMiddleware(
121+
this.serviceContainer,
122+
() => this.languageServerProxy.languageClient,
123+
this.lsVersion,
124+
);
121125
options.middleware = this.middleware;
122126

123127
// Make sure the middleware is connected if we restart and we we're already connected.

0 commit comments

Comments
 (0)