Skip to content

Commit a8a0e59

Browse files
anthonykim1DonJayamanne
authored andcommitted
Add Interactive REPL Experiment (#23235)
Allow users to use Interactive Window UI with Python custom REPL controller instead of iPykernel. Closes #23175 Closes #23174 Closes #23029 Majority of: #23332 Context menu under Python for running Python REPL code using IW UI should only appear when user's ```pythonRunREPL``` experiment is enabled.
1 parent f2313f9 commit a8a0e59

File tree

15 files changed

+453
-10
lines changed

15 files changed

+453
-10
lines changed

.vscode/launch.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,7 @@
252252
],
253253
"compounds": [
254254
{
255-
"name": "Debug Test Discovery",
255+
"name": "Debug Python and Extension",
256256
"configurations": ["Python: Attach Listen", "Extension"]
257257
}
258258
]

package.json

+24-4
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,11 @@
306306
"command": "python.execSelectionInTerminal",
307307
"title": "%python.command.python.execSelectionInTerminal.title%"
308308
},
309+
{
310+
"category": "Python",
311+
"command": "python.execInREPL",
312+
"title": "%python.command.python.execInREPL.title%"
313+
},
309314
{
310315
"category": "Python",
311316
"command": "python.launchTensorBoard",
@@ -437,7 +442,8 @@
437442
"pythonDiscoveryUsingWorkers",
438443
"pythonTestAdapter",
439444
"pythonREPLSmartSend",
440-
"pythonRecommendTensorboardExt"
445+
"pythonRecommendTensorboardExt",
446+
"pythonRunREPL"
441447
],
442448
"enumDescriptions": [
443449
"%python.experiments.All.description%",
@@ -447,7 +453,8 @@
447453
"%python.experiments.pythonDiscoveryUsingWorkers.description%",
448454
"%python.experiments.pythonTestAdapter.description%",
449455
"%python.experiments.pythonREPLSmartSend.description%",
450-
"%python.experiments.pythonRecommendTensorboardExt.description%"
456+
"%python.experiments.pythonRecommendTensorboardExt.description%",
457+
"%python.experiments.pythonRunREPL.description%"
451458
]
452459
},
453460
"scope": "window",
@@ -465,7 +472,8 @@
465472
"pythonTerminalEnvVarActivation",
466473
"pythonDiscoveryUsingWorkers",
467474
"pythonTestAdapter",
468-
"pythonREPLSmartSend"
475+
"pythonREPLSmartSend",
476+
"pythonRunREPL"
469477
],
470478
"enumDescriptions": [
471479
"%python.experiments.All.description%",
@@ -474,7 +482,8 @@
474482
"%python.experiments.pythonTerminalEnvVarActivation.description%",
475483
"%python.experiments.pythonDiscoveryUsingWorkers.description%",
476484
"%python.experiments.pythonTestAdapter.description%",
477-
"%python.experiments.pythonREPLSmartSend.description%"
485+
"%python.experiments.pythonREPLSmartSend.description%",
486+
"%python.experiments.pythonRunREPL.description%"
478487
]
479488
},
480489
"scope": "window",
@@ -1254,6 +1263,12 @@
12541263
"title": "%python.command.python.execSelectionInTerminal.title%",
12551264
"when": "!virtualWorkspace && shellExecutionSupported && editorLangId == python"
12561265
},
1266+
{
1267+
"category": "Python",
1268+
"command": "python.execInREPL",
1269+
"title": "%python.command.python.execInREPL.title%",
1270+
"when": "false"
1271+
},
12571272
{
12581273
"category": "Python",
12591274
"command": "python.launchTensorBoard",
@@ -1353,6 +1368,11 @@
13531368
"command": "python.execSelectionInTerminal",
13541369
"group": "Python",
13551370
"when": "editorFocus && editorLangId == python && !virtualWorkspace && shellExecutionSupported"
1371+
},
1372+
{
1373+
"command": "python.execInREPL",
1374+
"group": "Python",
1375+
"when": "editorFocus && editorLangId == python && !virtualWorkspace && shellExecutionSupported && pythonRunREPL"
13561376
}
13571377
],
13581378
"editor/title": [

package.nls.json

+2
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"python.command.python.configureTests.title": "Configure Tests",
1515
"python.command.testing.rerunFailedTests.title": "Rerun Failed Tests",
1616
"python.command.python.execSelectionInTerminal.title": "Run Selection/Line in Python Terminal",
17+
"python.command.python.execInREPL.title": "Run Selection/Line in Python REPL",
1718
"python.command.python.execSelectionInDjangoShell.title": "Run Selection/Line in Django Shell",
1819
"python.command.python.reportIssue.title": "Report Issue...",
1920
"python.command.python.enableSourceMapSupport.title": "Enable Source Map Support For Extension Debugging",
@@ -44,6 +45,7 @@
4445
"python.experiments.pythonTestAdapter.description": "Denotes the Python Test Adapter experiment.",
4546
"python.experiments.pythonREPLSmartSend.description": "Denotes the Python REPL Smart Send experiment.",
4647
"python.experiments.pythonRecommendTensorboardExt.description": "Denotes the Tensorboard Extension recommendation experiment.",
48+
"python.experiments.pythonRunREPL.description": "Enables users to run code in interactive Python REPL.",
4749
"python.globalModuleInstallation.description": "Whether to install Python modules globally when not using an environment.",
4850
"python.languageServer.description": "Defines type of the language server.",
4951
"python.languageServer.defaultDescription": "Automatically select a language server: Pylance if installed and available, otherwise fallback to Jedi.",

python_files/python_server.py

+167
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
from typing import Dict, List, Optional, Union
2+
3+
import sys
4+
import json
5+
import contextlib
6+
import io
7+
import traceback
8+
import uuid
9+
10+
STDIN = sys.stdin
11+
STDOUT = sys.stdout
12+
STDERR = sys.stderr
13+
USER_GLOBALS = {}
14+
15+
16+
def send_message(msg: str):
17+
length_msg = len(msg)
18+
STDOUT.buffer.write(f"Content-Length: {length_msg}\r\n\r\n{msg}".encode(encoding="utf-8"))
19+
STDOUT.buffer.flush()
20+
21+
22+
def print_log(msg: str):
23+
send_message(json.dumps({"jsonrpc": "2.0", "method": "log", "params": msg}))
24+
25+
26+
def send_response(response: str, response_id: int):
27+
send_message(json.dumps({"jsonrpc": "2.0", "id": response_id, "result": response}))
28+
29+
30+
def send_request(params: Optional[Union[List, Dict]] = None):
31+
request_id = uuid.uuid4().hex
32+
if params is None:
33+
send_message(json.dumps({"jsonrpc": "2.0", "id": request_id, "method": "input"}))
34+
else:
35+
send_message(
36+
json.dumps({"jsonrpc": "2.0", "id": request_id, "method": "input", "params": params})
37+
)
38+
return request_id
39+
40+
41+
original_input = input
42+
43+
44+
def custom_input(prompt=""):
45+
try:
46+
send_request({"prompt": prompt})
47+
headers = get_headers()
48+
content_length = int(headers.get("Content-Length", 0))
49+
50+
if content_length:
51+
message_text = STDIN.read(content_length)
52+
message_json = json.loads(message_text)
53+
our_user_input = message_json["result"]["userInput"]
54+
return our_user_input
55+
except Exception:
56+
print_log(traceback.format_exc())
57+
58+
59+
# Set input to our custom input
60+
USER_GLOBALS["input"] = custom_input
61+
input = custom_input
62+
63+
64+
def handle_response(request_id):
65+
while not STDIN.closed:
66+
try:
67+
headers = get_headers()
68+
content_length = int(headers.get("Content-Length", 0))
69+
70+
if content_length:
71+
message_text = STDIN.read(content_length)
72+
message_json = json.loads(message_text)
73+
our_user_input = message_json["result"]["userInput"]
74+
if message_json["id"] == request_id:
75+
send_response(our_user_input, message_json["id"])
76+
elif message_json["method"] == "exit":
77+
sys.exit(0)
78+
79+
except Exception:
80+
print_log(traceback.format_exc())
81+
82+
83+
def exec_function(user_input):
84+
try:
85+
compile(user_input, "<stdin>", "eval")
86+
except SyntaxError:
87+
return exec
88+
return eval
89+
90+
91+
def execute(request, user_globals):
92+
str_output = CustomIO("<stdout>", encoding="utf-8")
93+
str_error = CustomIO("<stderr>", encoding="utf-8")
94+
95+
with redirect_io("stdout", str_output):
96+
with redirect_io("stderr", str_error):
97+
str_input = CustomIO("<stdin>", encoding="utf-8", newline="\n")
98+
with redirect_io("stdin", str_input):
99+
exec_user_input(request["params"], user_globals)
100+
send_response(str_output.get_value(), request["id"])
101+
102+
103+
def exec_user_input(user_input, user_globals):
104+
user_input = user_input[0] if isinstance(user_input, list) else user_input
105+
106+
try:
107+
callable = exec_function(user_input)
108+
retval = callable(user_input, user_globals)
109+
if retval is not None:
110+
print(retval)
111+
except KeyboardInterrupt:
112+
print(traceback.format_exc())
113+
except Exception:
114+
print(traceback.format_exc())
115+
116+
117+
class CustomIO(io.TextIOWrapper):
118+
"""Custom stream object to replace stdio."""
119+
120+
def __init__(self, name, encoding="utf-8", newline=None):
121+
self._buffer = io.BytesIO()
122+
self._custom_name = name
123+
super().__init__(self._buffer, encoding=encoding, newline=newline)
124+
125+
def close(self):
126+
"""Provide this close method which is used by some tools."""
127+
# This is intentionally empty.
128+
129+
def get_value(self) -> str:
130+
"""Returns value from the buffer as string."""
131+
self.seek(0)
132+
return self.read()
133+
134+
135+
@contextlib.contextmanager
136+
def redirect_io(stream: str, new_stream):
137+
"""Redirect stdio streams to a custom stream."""
138+
old_stream = getattr(sys, stream)
139+
setattr(sys, stream, new_stream)
140+
yield
141+
setattr(sys, stream, old_stream)
142+
143+
144+
def get_headers():
145+
headers = {}
146+
while line := STDIN.readline().strip():
147+
name, value = line.split(":", 1)
148+
headers[name] = value.strip()
149+
return headers
150+
151+
152+
if __name__ == "__main__":
153+
while not STDIN.closed:
154+
try:
155+
headers = get_headers()
156+
content_length = int(headers.get("Content-Length", 0))
157+
158+
if content_length:
159+
request_text = STDIN.read(content_length)
160+
request_json = json.loads(request_text)
161+
if request_json["method"] == "execute":
162+
execute(request_json, USER_GLOBALS)
163+
elif request_json["method"] == "exit":
164+
sys.exit(0)
165+
166+
except Exception:
167+
print_log(traceback.format_exc())

src/client/common/application/commands.ts

+1
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ interface ICommandNameWithoutArgumentTypeMapping {
3838
[Commands.Enable_SourceMap_Support]: [];
3939
[Commands.Exec_Selection_In_Terminal]: [];
4040
[Commands.Exec_Selection_In_Django_Shell]: [];
41+
[Commands.Exec_In_REPL]: [];
4142
[Commands.Create_Terminal]: [];
4243
[Commands.PickLocalProcess]: [];
4344
[Commands.ClearStorage]: [];

src/client/common/constants.ts

+1
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export namespace Commands {
4646
export const Exec_In_Terminal = 'python.execInTerminal';
4747
export const Exec_In_Terminal_Icon = 'python.execInTerminal-icon';
4848
export const Exec_In_Separate_Terminal = 'python.execInDedicatedTerminal';
49+
export const Exec_In_REPL = 'python.execInREPL';
4950
export const Exec_Selection_In_Django_Shell = 'python.execSelectionInDjangoShell';
5051
export const Exec_Selection_In_Terminal = 'python.execSelectionInTerminal';
5152
export const GetSelectedInterpreterPath = 'python.interpreterPath';

src/client/common/experiments/groups.ts

+5
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,8 @@ export enum RecommendTensobardExtension {
3030
export enum CreateEnvOnPipInstallTrigger {
3131
experiment = 'pythonCreateEnvOnPipInstall',
3232
}
33+
34+
// Experiment to enable running Python REPL using IW.
35+
export enum EnableRunREPL {
36+
experiment = 'pythonRunREPL',
37+
}

src/client/common/types.ts

+1
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ export interface ITerminalSettings {
200200

201201
export interface IREPLSettings {
202202
readonly enableREPLSmartSend: boolean;
203+
readonly enableIWREPL: boolean;
203204
}
204205

205206
export interface IExperiments {

src/client/extensionActivation.ts

+15-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
'use strict';
55

6-
import { DebugConfigurationProvider, debug, languages, window } from 'vscode';
6+
import { DebugConfigurationProvider, debug, languages, window, commands } from 'vscode';
77

88
import { registerTypes as activationRegisterTypes } from './activation/serviceRegistry';
99
import { IExtensionActivationManager } from './activation/types';
@@ -16,6 +16,7 @@ import { IFileSystem } from './common/platform/types';
1616
import {
1717
IConfigurationService,
1818
IDisposableRegistry,
19+
IExperimentService,
1920
IExtensions,
2021
IInterpreterPathService,
2122
ILogOutputChannel,
@@ -52,6 +53,8 @@ import { initializePersistentStateForTriggers } from './common/persistentState';
5253
import { logAndNotifyOnLegacySettings } from './logging/settingLogs';
5354
import { DebuggerTypeName } from './debugger/constants';
5455
import { StopWatch } from './common/utils/stopWatch';
56+
import { registerReplCommands } from './repl/replCommands';
57+
import { EnableRunREPL } from './common/experiments/groups';
5558

5659
export async function activateComponents(
5760
// `ext` is passed to any extra activation funcs.
@@ -105,6 +108,17 @@ export function activateFeatures(ext: ExtensionState, _components: Components):
105108
interpreterService,
106109
pathUtils,
107110
);
111+
112+
// Register native REPL context menu when in experiment
113+
const experimentService = ext.legacyIOC.serviceContainer.get<IExperimentService>(IExperimentService);
114+
commands.executeCommand('setContext', 'pythonRunREPL', false);
115+
if (experimentService) {
116+
const replExperimentValue = experimentService.inExperimentSync(EnableRunREPL.experiment);
117+
if (replExperimentValue) {
118+
registerReplCommands(ext.disposables, interpreterService);
119+
commands.executeCommand('setContext', 'pythonRunREPL', true);
120+
}
121+
}
108122
}
109123

110124
/// //////////////////////////

0 commit comments

Comments
 (0)