Skip to content

Commit 6384bcc

Browse files
Jedi-based Python code completion on gr.Code (#10812)
* Jedi-based code completion * Delete unused objects * add changeset * add changeset * Format --------- Co-authored-by: gradio-pr-bot <gradio-pr-bot@users.noreply.github.com>
1 parent a1862f5 commit 6384bcc

File tree

9 files changed

+229
-32
lines changed

9 files changed

+229
-32
lines changed

.changeset/silver-bears-draw.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@gradio/code": minor
3+
"@gradio/wasm": minor
4+
"gradio": minor
5+
---
6+
7+
feat:Jedi-based Python code completion on `gr.Code`

js/code/shared/Code.svelte

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@
1010
import { StateEffect, EditorState, type Extension } from "@codemirror/state";
1111
import { indentWithTab } from "@codemirror/commands";
1212
import { autocompletion, acceptCompletion } from "@codemirror/autocomplete";
13+
import { LanguageSupport } from "@codemirror/language";
1314
1415
import { basicDark } from "cm6-theme-basic-dark";
1516
import { basicLight } from "cm6-theme-basic-light";
1617
import { basicSetup } from "./extensions";
1718
import { getLanguageExtension } from "./language";
19+
import { create_pyodide_autocomplete } from "./autocomplete";
1820
1921
export let class_names = "";
2022
export let value = "";
@@ -42,8 +44,19 @@
4244
4345
$: get_lang(language);
4446
47+
const pyodide_autocomplete = create_pyodide_autocomplete();
48+
4549
async function get_lang(val: string): Promise<void> {
4650
const ext = await getLanguageExtension(val);
51+
if (
52+
pyodide_autocomplete &&
53+
val === "python" &&
54+
ext instanceof LanguageSupport
55+
) {
56+
(ext.support as Extension[]).push(
57+
ext.language.data.of({ autocomplete: pyodide_autocomplete })
58+
);
59+
}
4760
lang_extension = ext;
4861
}
4962

js/code/shared/autocomplete.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import type {
2+
CompletionContext,
3+
CompletionResult
4+
} from "@codemirror/autocomplete";
5+
import { getWorkerProxyContext } from "@gradio/wasm/svelte";
6+
import type { WorkerProxy } from "@gradio/wasm";
7+
8+
// Jedi's completion types to CodeMirror's completion types.
9+
// If not defined here, Jedi's completion types will be used.
10+
const completion_type_map: Record<string, string> = {
11+
module: "namespace"
12+
};
13+
14+
type CodeMirrorAutocompleteAsyncFn = (
15+
context: CompletionContext
16+
) => Promise<CompletionResult | null>;
17+
18+
export function create_pyodide_autocomplete(): CodeMirrorAutocompleteAsyncFn | null {
19+
let maybe_worker_proxy: WorkerProxy | undefined;
20+
try {
21+
maybe_worker_proxy = getWorkerProxyContext();
22+
} catch (e) {
23+
console.debug("Not in the Wasm env. Context-aware autocomplete disabled.");
24+
return null;
25+
}
26+
if (!maybe_worker_proxy) {
27+
return null;
28+
}
29+
const worker_proxy = maybe_worker_proxy;
30+
31+
return async function pyodide_autocomplete(
32+
context: CompletionContext
33+
): Promise<CompletionResult | null> {
34+
try {
35+
const completions = await worker_proxy.getCodeCompletions({
36+
code: context.state.doc.toString(),
37+
line: context.state.doc.lineAt(context.state.selection.main.head)
38+
.number,
39+
column:
40+
context.state.selection.main.head -
41+
context.state.doc.lineAt(context.state.selection.main.head).from
42+
});
43+
if (completions.length === 0) {
44+
return null;
45+
}
46+
return {
47+
from:
48+
context.state.selection.main.head -
49+
completions[0].completion_prefix_length,
50+
options: completions.map((completion) => ({
51+
label: completion.label,
52+
type: completion_type_map[completion.type] ?? completion.type,
53+
documentation: completion.docstring,
54+
// Items starting with "_" are private attributes and should be sorted last.
55+
boost: completion.label.startsWith("_") ? -1 : 0
56+
}))
57+
};
58+
} catch (e) {
59+
console.error("Error getting completions", e);
60+
return null;
61+
}
62+
};
63+
}

js/code/shared/language.ts

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,7 @@
11
import type { Extension } from "@codemirror/state";
22
import { StreamLanguage } from "@codemirror/language";
3-
import { sql } from "@codemirror/legacy-modes/mode/sql";
43
import { _ } from "svelte-i18n";
54

6-
const possible_langs = [
7-
"python",
8-
"c",
9-
"cpp",
10-
"markdown",
11-
"json",
12-
"html",
13-
"css",
14-
"javascript",
15-
"jinja2",
16-
"typescript",
17-
"yaml",
18-
"dockerfile",
19-
"shell",
20-
"r",
21-
"sql"
22-
];
23-
245
const sql_dialects = [
256
"standardSQL",
267
"msSQL",

js/wasm/src/message-types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { ASGIScope } from "./asgi-types";
2+
import type { CodeCompletionRequest } from "./webworker/code-completion/index";
23
import type { PackageData } from "pyodide";
34

45
export interface EmscriptenFile {
@@ -74,6 +75,10 @@ export interface InMessageInstall extends InMessageBase {
7475
requirements: string[];
7576
};
7677
}
78+
export interface InMessageCodeCompletion extends InMessageBase {
79+
type: "code-completion";
80+
data: CodeCompletionRequest;
81+
}
7782

7883
export interface InMessageEcho extends InMessageBase {
7984
// For debug
@@ -91,6 +96,7 @@ export type InMessage =
9196
| InMessageFileRename
9297
| InMessageFileUnlink
9398
| InMessageInstall
99+
| InMessageCodeCompletion
94100
| InMessageEcho;
95101

96102
export interface ReplyMessageSuccess<T = unknown> {
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import jedi
2+
3+
4+
def get_code_completions(code: str, line: int, column: int):
5+
script = jedi.Script(code)
6+
completions = script.complete(line, column)
7+
serializable_completions = [
8+
{
9+
"label": completion.name,
10+
"type": completion.type,
11+
"docstring": completion.docstring(raw=True),
12+
"completion_prefix_length": completion.get_completion_prefix_length(),
13+
}
14+
for completion in completions
15+
]
16+
return serializable_completions
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import type { PyodideInterface } from "pyodide";
2+
import code_completion_py from "./code_completion.py?raw";
3+
import type { PyProxy } from "pyodide/ffi";
4+
5+
export interface CodeCompletionRequest {
6+
code: string;
7+
// line is 1-based and column is 0-based due to Jedi's spec: https://jedi.readthedocs.io/en/latest/docs/api.html#jedi.Script
8+
line: number; // 1-based
9+
column: number; // 0-based
10+
}
11+
export interface CodeCompletion {
12+
label: string;
13+
type: string;
14+
docstring: string;
15+
completion_prefix_length: number;
16+
}
17+
export type CodeCompletionResponse = CodeCompletion[];
18+
19+
type GetCodeCompletionsPyFn = (
20+
code: string,
21+
line: number,
22+
column: number
23+
) => PyProxy;
24+
25+
export class CodeCompleter {
26+
private setupPromise: Promise<GetCodeCompletionsPyFn | null>;
27+
28+
constructor(private pyodide: PyodideInterface) {
29+
// NOTE: obviously this constructor has a side effect on the Pyodide instance.
30+
this.setupPromise = this.setup().catch((err) => {
31+
console.error("Error while setting up code completion", err);
32+
return null;
33+
});
34+
}
35+
36+
private async setup(): Promise<GetCodeCompletionsPyFn> {
37+
const micropip = this.pyodide.pyimport("micropip");
38+
await micropip.install.callKwargs(["jedi"], {
39+
keep_going: true
40+
});
41+
42+
this.pyodide.runPython(code_completion_py);
43+
44+
return this.pyodide.globals.get(
45+
"get_code_completions"
46+
) as GetCodeCompletionsPyFn;
47+
}
48+
49+
public async getCodeCompletions(
50+
request: CodeCompletionRequest
51+
): Promise<CodeCompletionResponse> {
52+
const getCodeCompletionsPyFn = await this.setupPromise;
53+
if (!getCodeCompletionsPyFn) {
54+
// Setting up failed, return empty response
55+
console.debug("Code completion setup failed, returning empty response");
56+
return [];
57+
}
58+
59+
const { code, line, column } = request;
60+
const completionsPy = getCodeCompletionsPyFn(code, line, column);
61+
const completions: CodeCompletionResponse = completionsPy.toJs({
62+
dict_converter: Object.fromEntries // dict -> object
63+
});
64+
// > ... If the return value is a PyProxy, you must explicitly destroy it or else it will be leaked.
65+
// https://pyodide.org/en/stable/usage/type-conversions.html#calling-python-objects-from-javascript
66+
completionsPy.destroy();
67+
68+
return completions;
69+
}
70+
}

js/wasm/src/webworker/index.ts

Lines changed: 39 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,16 +24,14 @@ import {
2424
import { patchRequirements, verifyRequirements } from "./requirements";
2525
import { makeAsgiRequest } from "./asgi";
2626
import { generateRandomString } from "./random";
27+
import { CodeCompleter } from "./code-completion";
2728
import scriptRunnerPySource from "./py/script_runner.py?raw";
2829
import unloadModulesPySource from "./py/unload_modules.py?raw";
2930

3031
importScripts("https://cdn.jsdelivr.net/pyodide/v0.27.3/full/pyodide.js");
3132

3233
type MessageTransceiver = DedicatedWorkerGlobalScope | MessagePort;
3334

34-
let pyodide: PyodideInterface;
35-
let micropip: PyProxy;
36-
3735
declare let loadPyodide: typeof loadPyodideValue; // This will be dynamically loaded by importScript.
3836

3937
let call_asgi_app_from_js: (
@@ -56,6 +54,8 @@ let run_script: (
5654
let unload_local_modules: (target_dir_path?: string) => void;
5755

5856
async function installPackages(
57+
pyodide: PyodideInterface,
58+
micropip: PyProxy,
5959
requirements: string[],
6060
retries = 3
6161
): Promise<void> {
@@ -80,15 +80,20 @@ async function installPackages(
8080
}
8181
}
8282

83+
interface GradioLitePyodideEnvironment {
84+
pyodide: PyodideInterface;
85+
micropip: PyProxy;
86+
codeCompleter: CodeCompleter;
87+
}
8388
async function initializeEnvironment(
8489
options: InMessageInitEnv["data"],
8590
updateProgress: (log: string) => void,
8691
stdout: (output: string) => void,
8792
stderr: (output: string) => void
88-
): Promise<void> {
93+
): Promise<GradioLitePyodideEnvironment> {
8994
console.debug("Loading Pyodide.");
9095
updateProgress("Loading Pyodide");
91-
pyodide = await loadPyodide({
96+
const pyodide = await loadPyodide({
9297
stdout,
9398
stderr
9499
});
@@ -97,7 +102,7 @@ async function initializeEnvironment(
97102
console.debug("Loading micropip");
98103
updateProgress("Loading micropip");
99104
await pyodide.loadPackage("micropip");
100-
micropip = pyodide.pyimport("micropip");
105+
const micropip = pyodide.pyimport("micropip");
101106
console.debug("micropip is loaded.");
102107

103108
const gradioWheelUrls = [
@@ -108,7 +113,7 @@ async function initializeEnvironment(
108113
updateProgress("Loading Gradio wheels");
109114
await pyodide.loadPackage(["ssl", "setuptools"]);
110115
await micropip.add_mock_package("ffmpy", "0.3.0");
111-
await installPackages(gradioWheelUrls);
116+
await installPackages(pyodide, micropip, gradioWheelUrls);
112117
console.debug("Gradio wheels are loaded.");
113118

114119
console.debug("Mocking os module methods.");
@@ -196,9 +201,19 @@ anyio.to_thread.run_sync = mocked_anyio_to_thread_run_sync
196201
console.debug("Python utility functions are set up.");
197202

198203
updateProgress("Initialization completed");
204+
205+
const codeCompleter = new CodeCompleter(pyodide);
206+
207+
return {
208+
pyodide,
209+
micropip,
210+
codeCompleter
211+
};
199212
}
200213

201214
async function initializeApp(
215+
pyodide: PyodideInterface,
216+
micropip: PyProxy,
202217
appId: string,
203218
options: InMessageInitApp["data"],
204219
updateProgress: (log: string) => void,
@@ -242,7 +257,7 @@ async function initializeApp(
242257

243258
console.debug("Installing packages.", options.requirements);
244259
updateProgress("Installing packages");
245-
await installPackages(options.requirements);
260+
await installPackages(pyodide, micropip, options.requirements);
246261
console.debug("Packages are installed.");
247262

248263
console.debug("Auto-loading modules.");
@@ -298,7 +313,8 @@ if ("postMessage" in ctx) {
298313
}
299314

300315
// Environment initialization is global and should be done only once, so its promise is managed in a global scope.
301-
let envReadyPromise: Promise<void> | undefined = undefined;
316+
let envReadyPromise: Promise<GradioLitePyodideEnvironment> | undefined =
317+
undefined;
302318

303319
function setupMessageHandler(receiver: MessageTransceiver): void {
304320
// A concept of "app" is introduced to support multiple apps in a single worker.
@@ -404,13 +420,15 @@ function setupMessageHandler(receiver: MessageTransceiver): void {
404420
if (envReadyPromise == null) {
405421
throw new Error("Pyodide Initialization is not started.");
406422
}
407-
await envReadyPromise;
423+
const { pyodide, micropip, codeCompleter } = await envReadyPromise;
408424

409425
const gradio = pyodide.pyimport("gradio");
410426
gradio.wasm_utils.register_error_traceback_callback(appId, onPythonError);
411427

412428
if (msg.type === "init-app") {
413429
appReadyPromise = initializeApp(
430+
pyodide,
431+
micropip,
414432
appId,
415433
msg.data,
416434
updateProgress,
@@ -541,7 +559,7 @@ function setupMessageHandler(receiver: MessageTransceiver): void {
541559

542560
console.debug("Install the requirements:", requirements);
543561
verifyRequirements(requirements); // Blocks the not allowed wheel URL schemes.
544-
await installPackages(requirements)
562+
await installPackages(pyodide, micropip, requirements)
545563
.then(() => {
546564
if (requirements.includes("matplotlib")) {
547565
// Ref: https://github.com/pyodide/pyodide/issues/561#issuecomment-1992613717
@@ -566,6 +584,16 @@ except ImportError:
566584
});
567585
break;
568586
}
587+
case "code-completion": {
588+
const request = msg.data;
589+
const completions = await codeCompleter.getCodeCompletions(request);
590+
const replyMessage: ReplyMessageSuccess = {
591+
type: "reply:success",
592+
data: completions
593+
};
594+
messagePort.postMessage(replyMessage);
595+
break;
596+
}
569597
}
570598
} catch (error) {
571599
console.error(error);

0 commit comments

Comments
 (0)