Skip to content

Commit 44fd2d8

Browse files
thy09Heyi Tang
andauthored
Use jupyter inspect to get signature of dynamic functions in notebook editor when language server doesn't provide enough hint. (#13259)
* Update the signature for jupyterNotebook.inspect to support inspect functions. * Update interface signature of INotebook.inspect in types.ts * Update affected calls of INotebook.inspect. * Add news entry * Add logic in datascience/interactive-common/intellisense/intellisenseProvider.ts to use notebook.inspect for Hover message and SignatureHelp when PyLance doesn't provide good result. Co-authored-by: Heyi Tang <[email protected]>
1 parent 98f5b49 commit 44fd2d8

File tree

7 files changed

+209
-33
lines changed

7 files changed

+209
-33
lines changed

news/1 Enhancements/13259.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Use jupyter inspect to get signature of dynamic functions in notebook editor when language server doesn't provide enough hint.

src/client/datascience/interactive-common/intellisense/intellisenseProvider.ts

Lines changed: 202 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ import {
1313
Event,
1414
EventEmitter,
1515
Hover,
16+
MarkdownString,
1617
SignatureHelpContext,
18+
SignatureInformation,
1719
TextDocumentContentChangeEvent,
1820
Uri
1921
} from 'vscode';
@@ -64,6 +66,24 @@ import {
6466
} from './conversion';
6567
import { IntellisenseDocument } from './intellisenseDocument';
6668

69+
// These regexes are used to get the text from jupyter output by recognizing escape charactor \x1b
70+
const DocStringRegex = /\x1b\[1;31mDocstring:\x1b\[0m\s+([\s\S]*?)\r?\n\x1b\[1;31m/;
71+
const SignatureTextRegex = /\x1b\[1;31mSignature:\x1b\[0m\s+([\s\S]*?)\r?\n\x1b\[1;31m/;
72+
const TypeRegex = /\x1b\[1;31mType:\x1b\[0m\s+(.*)/;
73+
74+
// This regex is to parse the name and the signature in the signature text from Jupyter,
75+
// Example string: some_func(param1=1, param2=2) -> int
76+
// match[1]: some_func
77+
// match[2]: (param1=1, param2=2) -> int
78+
const SignatureRegex = /(.+?)(\(([\s\S]*)\)(\s*->[\s\S]*)?)/;
79+
const GeneralCallableSignature = '(*args, **kwargs)';
80+
// This regex is to detect whether a markdown provided by the language server is a callable and get its signature.
81+
// Example string: ```python\n(function) some_func: (*args, **kwargs) -> None\n```
82+
// match[1]: (*args, **kwargs)
83+
// If the string is not a callable, no match will be found.
84+
// Example string: ```python\n(variable) some_var: Any\n```
85+
const CallableRegex = /python\n\(.+?\) \S+?: (\([\s\S]+?\))/;
86+
6787
// tslint:disable:no-any
6888
@injectable()
6989
export class IntellisenseProvider implements IInteractiveWindowListener {
@@ -253,6 +273,7 @@ export class IntellisenseProvider implements IInteractiveWindowListener {
253273
incomplete: false
254274
};
255275
}
276+
256277
protected async provideHover(
257278
position: monacoEditor.Position,
258279
wordAtPosition: string | undefined,
@@ -266,9 +287,27 @@ export class IntellisenseProvider implements IInteractiveWindowListener {
266287
]);
267288
if (!variableHover && languageServer && document) {
268289
const docPos = document.convertToDocumentPosition(cellId, position.lineNumber, position.column);
269-
const result = await languageServer.provideHover(document, docPos, token);
270-
if (result) {
271-
return convertToMonacoHover(result);
290+
const [lsResult, jupyterResult] = await Promise.all([
291+
languageServer.provideHover(document, docPos, token),
292+
Promise.race([
293+
this.provideJupyterHover(position, cellId, token),
294+
sleep(Settings.IntellisenseTimeout).then(() => undefined)
295+
])
296+
]);
297+
const jupyterHover = jupyterResult ? convertToMonacoHover(jupyterResult) : undefined;
298+
const lsHover = lsResult ? convertToMonacoHover(lsResult) : undefined;
299+
// If lsHover is not valid or it is not a callable with hints,
300+
// while the jupyter hover is a callable with hint,
301+
// we prefer to use jupyterHover which provides better callable hints from jupyter kernel.
302+
const preferJupyterHover =
303+
jupyterHover &&
304+
jupyterHover.contents[0] &&
305+
this.isCallableWithGoodHint(jupyterHover.contents[0].value) &&
306+
(!lsHover || !lsHover.contents[0] || !this.isCallableWithGoodHint(lsHover.contents[0].value));
307+
if (preferJupyterHover && jupyterHover) {
308+
return jupyterHover;
309+
} else if (lsHover) {
310+
return lsHover;
272311
}
273312
} else if (variableHover) {
274313
return convertToMonacoHover(variableHover);
@@ -278,6 +317,7 @@ export class IntellisenseProvider implements IInteractiveWindowListener {
278317
contents: []
279318
};
280319
}
320+
281321
protected async provideSignatureHelp(
282322
position: monacoEditor.Position,
283323
context: monacoEditor.languages.SignatureHelpContext,
@@ -349,6 +389,37 @@ export class IntellisenseProvider implements IInteractiveWindowListener {
349389
}
350390
}
351391

392+
private isCallableWithGoodHint(markdown: string): boolean {
393+
// Check whether the markdown is a callable with the hint that is not (*args, **kwargs)
394+
const match = CallableRegex.exec(markdown);
395+
return match !== null && match[1] !== GeneralCallableSignature;
396+
}
397+
398+
private convertDocMarkDown(doc: string): string {
399+
// For the argument definitions (Starts with :param/:type/:return), to make markdown works well, we need to:
400+
// 1. Add one more line break;
401+
// 2. Replace '_' with '\_';
402+
const docLines = doc.splitLines({ trim: false, removeEmptyEntries: false });
403+
return docLines.map((line) => (line.startsWith(':') ? `\n${line.replace(/_/g, '\\_')}` : line)).join('\n');
404+
}
405+
406+
private async provideJupyterHover(
407+
position: monacoEditor.Position,
408+
cellId: string,
409+
token: CancellationToken
410+
): Promise<Hover | undefined> {
411+
// Currently we only get the callable information from jupyter,
412+
// this aims to handle the case that language server cannot well recognize the dynamically created callables.
413+
const callable = await this.getJupyterCallableInspectResult(position, cellId, token);
414+
if (callable) {
415+
const signatureMarkdown = `\`\`\`python\n(${callable.type}) ${callable.name}: ${callable.signature}\n\`\`\``;
416+
const docMarkdown = this.convertDocMarkDown(callable.doc);
417+
const result = new MarkdownString(`${signatureMarkdown}\n\n${docMarkdown}`);
418+
return { contents: [result] };
419+
}
420+
return undefined;
421+
}
422+
352423
private dispatchMessage<M extends IInteractiveWindowMapping, T extends keyof M>(
353424
_message: T,
354425
payload: any,
@@ -465,6 +536,92 @@ export class IntellisenseProvider implements IInteractiveWindowListener {
465536
);
466537
}
467538

539+
private convertCallableInspectResult(text: string) {
540+
// This method will parse the inspect result from jupyter and get the following values of a callable:
541+
// Name, Type (function or method), Signature, Documentation
542+
543+
const docMatch = DocStringRegex.exec(text);
544+
// Variable type will be used in hover result, it could be function/method
545+
const typeMatch = TypeRegex.exec(text);
546+
547+
const signatureTextMatch = SignatureTextRegex.exec(text);
548+
// The signature text returned by jupyter contains escape sequences, we need to remove them.
549+
// See https://en.wikipedia.org/wiki/ANSI_escape_code#Escape_sequences
550+
const signatureText = signatureTextMatch ? signatureTextMatch[1].replace(/\x1b\[[;\d]+m/g, '') : '';
551+
// Use this to get different parts of the signature: 1: Callable name, 2: Callable signature
552+
const signatureMatch = SignatureRegex.exec(signatureText);
553+
554+
if (docMatch && typeMatch && signatureMatch) {
555+
return {
556+
name: signatureMatch[1],
557+
type: typeMatch[1],
558+
signature: signatureMatch[2],
559+
doc: docMatch[1]
560+
};
561+
}
562+
return undefined;
563+
}
564+
565+
private async getJupyterCallableInspectResult(
566+
position: monacoEditor.Position,
567+
cellId: string,
568+
cancelToken: CancellationToken
569+
) {
570+
try {
571+
const [activeNotebook, document] = await Promise.all([this.getNotebook(cancelToken), this.getDocument()]);
572+
if (activeNotebook && document) {
573+
const data = document.getCellData(cellId);
574+
if (data) {
575+
const offsetInCode = this.getOffsetInCode(data.text, position);
576+
const jupyterResults = await activeNotebook.inspect(data.text, offsetInCode, cancelToken);
577+
if (jupyterResults && jupyterResults.hasOwnProperty('text/plain')) {
578+
return this.convertCallableInspectResult((jupyterResults as any)['text/plain'].toString());
579+
}
580+
}
581+
}
582+
} catch (e) {
583+
if (!(e instanceof CancellationError)) {
584+
traceWarning(e);
585+
}
586+
}
587+
return undefined;
588+
}
589+
590+
private async provideJupyterSignatureHelp(
591+
position: monacoEditor.Position,
592+
cellId: string,
593+
cancelToken: CancellationToken
594+
): Promise<monacoEditor.languages.SignatureHelp> {
595+
const callable = await this.getJupyterCallableInspectResult(position, cellId, cancelToken);
596+
let signatures: SignatureInformation[] = [];
597+
if (callable) {
598+
const signatureInfo: SignatureInformation = {
599+
label: callable.signature,
600+
documentation: callable.doc,
601+
parameters: []
602+
};
603+
signatures = [signatureInfo];
604+
}
605+
return {
606+
signatures: signatures,
607+
activeParameter: 0,
608+
activeSignature: 0
609+
};
610+
}
611+
612+
private getOffsetInCode(text: string, position: monacoEditor.Position) {
613+
const lines = text.splitLines({ trim: false, removeEmptyEntries: false });
614+
return lines.reduce((a: number, c: string, i: number) => {
615+
if (i < position.lineNumber - 1) {
616+
return a + c.length + 1;
617+
} else if (i === position.lineNumber - 1) {
618+
return a + position.column - 1;
619+
} else {
620+
return a;
621+
}
622+
}, 0);
623+
}
624+
468625
private async provideJupyterCompletionItems(
469626
position: monacoEditor.Position,
470627
_context: monacoEditor.languages.CompletionContext,
@@ -477,17 +634,7 @@ export class IntellisenseProvider implements IInteractiveWindowListener {
477634
const data = document.getCellData(cellId);
478635

479636
if (data) {
480-
const lines = data.text.splitLines({ trim: false, removeEmptyEntries: false });
481-
const offsetInCode = lines.reduce((a: number, c: string, i: number) => {
482-
if (i < position.lineNumber - 1) {
483-
return a + c.length + 1;
484-
} else if (i === position.lineNumber - 1) {
485-
return a + position.column - 1;
486-
} else {
487-
return a;
488-
}
489-
}, 0);
490-
637+
const offsetInCode = this.getOffsetInCode(data.text, position);
491638
const jupyterResults = await activeNotebook.getCompletion(data.text, offsetInCode, cancelToken);
492639
if (jupyterResults && jupyterResults.matches) {
493640
const filteredMatches = this.filterJupyterMatches(document, jupyterResults, cellId, position);
@@ -585,20 +732,48 @@ export class IntellisenseProvider implements IInteractiveWindowListener {
585732
private handleSignatureHelpRequest(request: IProvideSignatureHelpRequest) {
586733
const cancelSource = new CancellationTokenSource();
587734
this.cancellationSources.set(request.requestId, cancelSource);
588-
this.postTimedResponse(
589-
[this.provideSignatureHelp(request.position, request.context, request.cellId, cancelSource.token)],
590-
InteractiveWindowMessages.ProvideSignatureHelpResponse,
591-
(s) => {
592-
if (s && s[0]) {
593-
return { signatureHelp: s[0]!, requestId: request.requestId };
594-
} else {
595-
return {
596-
signatureHelp: { signatures: [], activeParameter: 0, activeSignature: 0 },
597-
requestId: request.requestId
598-
};
599-
}
735+
736+
const getSignatureHelp = async (): Promise<monacoEditor.languages.SignatureHelp> => {
737+
const jupyterSignatureHelp = this.provideJupyterSignatureHelp(
738+
request.position,
739+
request.cellId,
740+
cancelSource.token
741+
);
742+
743+
const lsSignatureHelp = this.provideSignatureHelp(
744+
request.position,
745+
request.context,
746+
request.cellId,
747+
cancelSource.token
748+
);
749+
750+
const defaultHelp = {
751+
signatures: [],
752+
activeParameter: 0,
753+
activeSignature: 0
754+
};
755+
756+
const [lsHelp, jupyterHelp] = await Promise.all([
757+
lsSignatureHelp,
758+
Promise.race([jupyterSignatureHelp, sleep(Settings.IntellisenseTimeout).then(() => defaultHelp)])
759+
]);
760+
// Only when language server result is not valid or the signature is (*args, **kwargs) , we prefer to use the result from jupyter.
761+
const preferJupyterHelp =
762+
(!lsHelp.signatures[0] || lsHelp.signatures[0].label.startsWith(GeneralCallableSignature)) &&
763+
jupyterHelp.signatures[0];
764+
return preferJupyterHelp ? jupyterHelp : lsHelp;
765+
};
766+
767+
this.postTimedResponse([getSignatureHelp()], InteractiveWindowMessages.ProvideSignatureHelpResponse, (s) => {
768+
if (s && s[0]) {
769+
return { signatureHelp: s[0]!, requestId: request.requestId };
770+
} else {
771+
return {
772+
signatureHelp: { signatures: [], activeParameter: 0, activeSignature: 0 },
773+
requestId: request.requestId
774+
};
600775
}
601-
);
776+
});
602777
}
603778

604779
private async update(request: NotebookModelChange): Promise<void> {

src/client/datascience/jupyter/jupyterNotebook.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -354,7 +354,7 @@ export class JupyterNotebookBase implements INotebook {
354354
return deferred.promise;
355355
}
356356

357-
public inspect(code: string, cancelToken?: CancellationToken): Promise<JSONObject> {
357+
public inspect(code: string, offsetInCode = 0, cancelToken?: CancellationToken): Promise<JSONObject> {
358358
// Create a deferred that will fire when the request completes
359359
const deferred = createDeferred<JSONObject>();
360360

@@ -366,7 +366,7 @@ export class JupyterNotebookBase implements INotebook {
366366
} else {
367367
// Ask session for inspect result
368368
this.session
369-
.requestInspect({ code, cursor_pos: 0, detail_level: 0 })
369+
.requestInspect({ code, cursor_pos: offsetInCode, detail_level: 0 })
370370
.then((r) => {
371371
if (r && r.content.status === 'ok') {
372372
deferred.resolve(r.content.data);

src/client/datascience/jupyter/kernelVariables.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -395,7 +395,7 @@ export class KernelVariables implements IJupyterVariables {
395395
): Promise<IJupyterVariable> {
396396
let result = { ...targetVariable };
397397
if (notebook) {
398-
const output = await notebook.inspect(targetVariable.name, token);
398+
const output = await notebook.inspect(targetVariable.name, 0, token);
399399

400400
// Should be a text/plain inside of it (at least IPython does this)
401401
if (output && output.hasOwnProperty('text/plain')) {

src/client/datascience/jupyter/liveshare/hostJupyterNotebook.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ export class HostJupyterNotebook
9696
this.onGetSysInfoRequest(cancellation)
9797
);
9898
service.onRequest(LiveShareCommands.inspect, (args: any[], cancellation: CancellationToken) =>
99-
this.inspect(args[0], cancellation)
99+
this.inspect(args[0], 0, cancellation)
100100
);
101101
service.onRequest(LiveShareCommands.restart, (args: any[], cancellation: CancellationToken) =>
102102
this.onRestartRequest(

src/client/datascience/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ export interface INotebook extends IAsyncDisposable {
198198
cancelToken?: CancellationToken,
199199
silent?: boolean
200200
): Promise<ICell[]>;
201-
inspect(code: string, cancelToken?: CancellationToken): Promise<JSONObject>;
201+
inspect(code: string, offsetInCode?: number, cancelToken?: CancellationToken): Promise<JSONObject>;
202202
getCompletion(
203203
cellCode: string,
204204
offsetInCode: number,

src/test/datascience/mockJupyterNotebook.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ export class MockJupyterNotebook implements INotebook {
7373
throw new Error('Method not implemented');
7474
}
7575

76-
public inspect(_code: string, _cancelToken?: CancellationToken): Promise<JSONObject> {
76+
public inspect(_code: string, _offsetInCode = 0, _cancelToken?: CancellationToken): Promise<JSONObject> {
7777
return Promise.resolve({});
7878
}
7979

0 commit comments

Comments
 (0)