Skip to content

Commit

Permalink
Add Interactive REPL Experiment (microsoft#23235)
Browse files Browse the repository at this point in the history
Allow users to use Interactive Window UI with Python custom REPL
controller instead of iPykernel.
Closes microsoft#23175
Closes microsoft#23174 
Closes microsoft#23029
Majority of: microsoft#23332 

Context menu under Python for running Python REPL code using IW UI
should only appear when user's ```pythonRunREPL``` experiment is
enabled.
  • Loading branch information
anthonykim1 authored May 16, 2024
1 parent 889ec9d commit 6997f7b
Show file tree
Hide file tree
Showing 15 changed files with 453 additions and 10 deletions.
2 changes: 1 addition & 1 deletion .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@
],
"compounds": [
{
"name": "Debug Test Discovery",
"name": "Debug Python and Extension",
"configurations": ["Python: Attach Listen", "Extension"]
}
]
Expand Down
28 changes: 24 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,11 @@
"command": "python.execSelectionInTerminal",
"title": "%python.command.python.execSelectionInTerminal.title%"
},
{
"category": "Python",
"command": "python.execInREPL",
"title": "%python.command.python.execInREPL.title%"
},
{
"category": "Python",
"command": "python.launchTensorBoard",
Expand Down Expand Up @@ -437,7 +442,8 @@
"pythonDiscoveryUsingWorkers",
"pythonTestAdapter",
"pythonREPLSmartSend",
"pythonRecommendTensorboardExt"
"pythonRecommendTensorboardExt",
"pythonRunREPL"
],
"enumDescriptions": [
"%python.experiments.All.description%",
Expand All @@ -447,7 +453,8 @@
"%python.experiments.pythonDiscoveryUsingWorkers.description%",
"%python.experiments.pythonTestAdapter.description%",
"%python.experiments.pythonREPLSmartSend.description%",
"%python.experiments.pythonRecommendTensorboardExt.description%"
"%python.experiments.pythonRecommendTensorboardExt.description%",
"%python.experiments.pythonRunREPL.description%"
]
},
"scope": "window",
Expand All @@ -465,7 +472,8 @@
"pythonTerminalEnvVarActivation",
"pythonDiscoveryUsingWorkers",
"pythonTestAdapter",
"pythonREPLSmartSend"
"pythonREPLSmartSend",
"pythonRunREPL"
],
"enumDescriptions": [
"%python.experiments.All.description%",
Expand All @@ -474,7 +482,8 @@
"%python.experiments.pythonTerminalEnvVarActivation.description%",
"%python.experiments.pythonDiscoveryUsingWorkers.description%",
"%python.experiments.pythonTestAdapter.description%",
"%python.experiments.pythonREPLSmartSend.description%"
"%python.experiments.pythonREPLSmartSend.description%",
"%python.experiments.pythonRunREPL.description%"
]
},
"scope": "window",
Expand Down Expand Up @@ -1254,6 +1263,12 @@
"title": "%python.command.python.execSelectionInTerminal.title%",
"when": "!virtualWorkspace && shellExecutionSupported && editorLangId == python"
},
{
"category": "Python",
"command": "python.execInREPL",
"title": "%python.command.python.execInREPL.title%",
"when": "false"
},
{
"category": "Python",
"command": "python.launchTensorBoard",
Expand Down Expand Up @@ -1353,6 +1368,11 @@
"command": "python.execSelectionInTerminal",
"group": "Python",
"when": "editorFocus && editorLangId == python && !virtualWorkspace && shellExecutionSupported"
},
{
"command": "python.execInREPL",
"group": "Python",
"when": "editorFocus && editorLangId == python && !virtualWorkspace && shellExecutionSupported && pythonRunREPL"
}
],
"editor/title": [
Expand Down
2 changes: 2 additions & 0 deletions package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"python.command.python.configureTests.title": "Configure Tests",
"python.command.testing.rerunFailedTests.title": "Rerun Failed Tests",
"python.command.python.execSelectionInTerminal.title": "Run Selection/Line in Python Terminal",
"python.command.python.execInREPL.title": "Run Selection/Line in Python REPL",
"python.command.python.execSelectionInDjangoShell.title": "Run Selection/Line in Django Shell",
"python.command.python.reportIssue.title": "Report Issue...",
"python.command.python.enableSourceMapSupport.title": "Enable Source Map Support For Extension Debugging",
Expand Down Expand Up @@ -44,6 +45,7 @@
"python.experiments.pythonTestAdapter.description": "Denotes the Python Test Adapter experiment.",
"python.experiments.pythonREPLSmartSend.description": "Denotes the Python REPL Smart Send experiment.",
"python.experiments.pythonRecommendTensorboardExt.description": "Denotes the Tensorboard Extension recommendation experiment.",
"python.experiments.pythonRunREPL.description": "Enables users to run code in interactive Python REPL.",
"python.globalModuleInstallation.description": "Whether to install Python modules globally when not using an environment.",
"python.languageServer.description": "Defines type of the language server.",
"python.languageServer.defaultDescription": "Automatically select a language server: Pylance if installed and available, otherwise fallback to Jedi.",
Expand Down
167 changes: 167 additions & 0 deletions python_files/python_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
from typing import Dict, List, Optional, Union

import sys
import json
import contextlib
import io
import traceback
import uuid

STDIN = sys.stdin
STDOUT = sys.stdout
STDERR = sys.stderr
USER_GLOBALS = {}


def send_message(msg: str):
length_msg = len(msg)
STDOUT.buffer.write(f"Content-Length: {length_msg}\r\n\r\n{msg}".encode(encoding="utf-8"))
STDOUT.buffer.flush()


def print_log(msg: str):
send_message(json.dumps({"jsonrpc": "2.0", "method": "log", "params": msg}))


def send_response(response: str, response_id: int):
send_message(json.dumps({"jsonrpc": "2.0", "id": response_id, "result": response}))


def send_request(params: Optional[Union[List, Dict]] = None):
request_id = uuid.uuid4().hex
if params is None:
send_message(json.dumps({"jsonrpc": "2.0", "id": request_id, "method": "input"}))
else:
send_message(
json.dumps({"jsonrpc": "2.0", "id": request_id, "method": "input", "params": params})
)
return request_id


original_input = input


def custom_input(prompt=""):
try:
send_request({"prompt": prompt})
headers = get_headers()
content_length = int(headers.get("Content-Length", 0))

if content_length:
message_text = STDIN.read(content_length)
message_json = json.loads(message_text)
our_user_input = message_json["result"]["userInput"]
return our_user_input
except Exception:
print_log(traceback.format_exc())


# Set input to our custom input
USER_GLOBALS["input"] = custom_input
input = custom_input


def handle_response(request_id):
while not STDIN.closed:
try:
headers = get_headers()
content_length = int(headers.get("Content-Length", 0))

if content_length:
message_text = STDIN.read(content_length)
message_json = json.loads(message_text)
our_user_input = message_json["result"]["userInput"]
if message_json["id"] == request_id:
send_response(our_user_input, message_json["id"])
elif message_json["method"] == "exit":
sys.exit(0)

except Exception:
print_log(traceback.format_exc())


def exec_function(user_input):
try:
compile(user_input, "<stdin>", "eval")
except SyntaxError:
return exec
return eval


def execute(request, user_globals):
str_output = CustomIO("<stdout>", encoding="utf-8")
str_error = CustomIO("<stderr>", encoding="utf-8")

with redirect_io("stdout", str_output):
with redirect_io("stderr", str_error):
str_input = CustomIO("<stdin>", encoding="utf-8", newline="\n")
with redirect_io("stdin", str_input):
exec_user_input(request["params"], user_globals)
send_response(str_output.get_value(), request["id"])


def exec_user_input(user_input, user_globals):
user_input = user_input[0] if isinstance(user_input, list) else user_input

try:
callable = exec_function(user_input)
retval = callable(user_input, user_globals)
if retval is not None:
print(retval)
except KeyboardInterrupt:
print(traceback.format_exc())
except Exception:
print(traceback.format_exc())


class CustomIO(io.TextIOWrapper):
"""Custom stream object to replace stdio."""

def __init__(self, name, encoding="utf-8", newline=None):
self._buffer = io.BytesIO()
self._custom_name = name
super().__init__(self._buffer, encoding=encoding, newline=newline)

def close(self):
"""Provide this close method which is used by some tools."""
# This is intentionally empty.

def get_value(self) -> str:
"""Returns value from the buffer as string."""
self.seek(0)
return self.read()


@contextlib.contextmanager
def redirect_io(stream: str, new_stream):
"""Redirect stdio streams to a custom stream."""
old_stream = getattr(sys, stream)
setattr(sys, stream, new_stream)
yield
setattr(sys, stream, old_stream)


def get_headers():
headers = {}
while line := STDIN.readline().strip():
name, value = line.split(":", 1)
headers[name] = value.strip()
return headers


if __name__ == "__main__":
while not STDIN.closed:
try:
headers = get_headers()
content_length = int(headers.get("Content-Length", 0))

if content_length:
request_text = STDIN.read(content_length)
request_json = json.loads(request_text)
if request_json["method"] == "execute":
execute(request_json, USER_GLOBALS)
elif request_json["method"] == "exit":
sys.exit(0)

except Exception:
print_log(traceback.format_exc())
1 change: 1 addition & 0 deletions src/client/common/application/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ interface ICommandNameWithoutArgumentTypeMapping {
[Commands.Enable_SourceMap_Support]: [];
[Commands.Exec_Selection_In_Terminal]: [];
[Commands.Exec_Selection_In_Django_Shell]: [];
[Commands.Exec_In_REPL]: [];
[Commands.Create_Terminal]: [];
[Commands.PickLocalProcess]: [];
[Commands.ClearStorage]: [];
Expand Down
1 change: 1 addition & 0 deletions src/client/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export namespace Commands {
export const Exec_In_Terminal = 'python.execInTerminal';
export const Exec_In_Terminal_Icon = 'python.execInTerminal-icon';
export const Exec_In_Separate_Terminal = 'python.execInDedicatedTerminal';
export const Exec_In_REPL = 'python.execInREPL';
export const Exec_Selection_In_Django_Shell = 'python.execSelectionInDjangoShell';
export const Exec_Selection_In_Terminal = 'python.execSelectionInTerminal';
export const GetSelectedInterpreterPath = 'python.interpreterPath';
Expand Down
5 changes: 5 additions & 0 deletions src/client/common/experiments/groups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,8 @@ export enum RecommendTensobardExtension {
export enum CreateEnvOnPipInstallTrigger {
experiment = 'pythonCreateEnvOnPipInstall',
}

// Experiment to enable running Python REPL using IW.
export enum EnableRunREPL {
experiment = 'pythonRunREPL',
}
1 change: 1 addition & 0 deletions src/client/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ export interface ITerminalSettings {

export interface IREPLSettings {
readonly enableREPLSmartSend: boolean;
readonly enableIWREPL: boolean;
}

export interface IExperiments {
Expand Down
16 changes: 15 additions & 1 deletion src/client/extensionActivation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

'use strict';

import { DebugConfigurationProvider, debug, languages, window } from 'vscode';
import { DebugConfigurationProvider, debug, languages, window, commands } from 'vscode';

import { registerTypes as activationRegisterTypes } from './activation/serviceRegistry';
import { IExtensionActivationManager } from './activation/types';
Expand All @@ -16,6 +16,7 @@ import { IFileSystem } from './common/platform/types';
import {
IConfigurationService,
IDisposableRegistry,
IExperimentService,
IExtensions,
IInterpreterPathService,
ILogOutputChannel,
Expand Down Expand Up @@ -52,6 +53,8 @@ import { initializePersistentStateForTriggers } from './common/persistentState';
import { logAndNotifyOnLegacySettings } from './logging/settingLogs';
import { DebuggerTypeName } from './debugger/constants';
import { StopWatch } from './common/utils/stopWatch';
import { registerReplCommands } from './repl/replCommands';
import { EnableRunREPL } from './common/experiments/groups';

export async function activateComponents(
// `ext` is passed to any extra activation funcs.
Expand Down Expand Up @@ -105,6 +108,17 @@ export function activateFeatures(ext: ExtensionState, _components: Components):
interpreterService,
pathUtils,
);

// Register native REPL context menu when in experiment
const experimentService = ext.legacyIOC.serviceContainer.get<IExperimentService>(IExperimentService);
commands.executeCommand('setContext', 'pythonRunREPL', false);
if (experimentService) {
const replExperimentValue = experimentService.inExperimentSync(EnableRunREPL.experiment);
if (replExperimentValue) {
registerReplCommands(ext.disposables, interpreterService);
commands.executeCommand('setContext', 'pythonRunREPL', true);
}
}
}

/// //////////////////////////
Expand Down
Loading

0 comments on commit 6997f7b

Please sign in to comment.