diff --git a/.vscode/launch.json b/.vscode/launch.json index a4a5104c2d22..4dc107853fc6 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -252,7 +252,7 @@ ], "compounds": [ { - "name": "Debug Test Discovery", + "name": "Debug Python and Extension", "configurations": ["Python: Attach Listen", "Extension"] } ] diff --git a/package.json b/package.json index 45e4b5ee3e5c..b3dd496d355c 100644 --- a/package.json +++ b/package.json @@ -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", @@ -437,7 +442,8 @@ "pythonDiscoveryUsingWorkers", "pythonTestAdapter", "pythonREPLSmartSend", - "pythonRecommendTensorboardExt" + "pythonRecommendTensorboardExt", + "pythonRunREPL" ], "enumDescriptions": [ "%python.experiments.All.description%", @@ -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", @@ -465,7 +472,8 @@ "pythonTerminalEnvVarActivation", "pythonDiscoveryUsingWorkers", "pythonTestAdapter", - "pythonREPLSmartSend" + "pythonREPLSmartSend", + "pythonRunREPL" ], "enumDescriptions": [ "%python.experiments.All.description%", @@ -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", @@ -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", @@ -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": [ diff --git a/package.nls.json b/package.nls.json index e7c82f86b243..aa84f31a91b2 100644 --- a/package.nls.json +++ b/package.nls.json @@ -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", @@ -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.", diff --git a/python_files/python_server.py b/python_files/python_server.py new file mode 100644 index 000000000000..4d27a168bc4c --- /dev/null +++ b/python_files/python_server.py @@ -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, "", "eval") + except SyntaxError: + return exec + return eval + + +def execute(request, user_globals): + str_output = CustomIO("", encoding="utf-8") + str_error = CustomIO("", encoding="utf-8") + + with redirect_io("stdout", str_output): + with redirect_io("stderr", str_error): + str_input = CustomIO("", 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()) diff --git a/src/client/common/application/commands.ts b/src/client/common/application/commands.ts index 30ba5d84cf5f..626321566332 100644 --- a/src/client/common/application/commands.ts +++ b/src/client/common/application/commands.ts @@ -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]: []; diff --git a/src/client/common/constants.ts b/src/client/common/constants.ts index 0eaade703371..663b932c8542 100644 --- a/src/client/common/constants.ts +++ b/src/client/common/constants.ts @@ -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'; diff --git a/src/client/common/experiments/groups.ts b/src/client/common/experiments/groups.ts index 81f157751346..543b1e27516f 100644 --- a/src/client/common/experiments/groups.ts +++ b/src/client/common/experiments/groups.ts @@ -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', +} diff --git a/src/client/common/types.ts b/src/client/common/types.ts index 67fcf5c7b700..8edc76ff2bff 100644 --- a/src/client/common/types.ts +++ b/src/client/common/types.ts @@ -200,6 +200,7 @@ export interface ITerminalSettings { export interface IREPLSettings { readonly enableREPLSmartSend: boolean; + readonly enableIWREPL: boolean; } export interface IExperiments { diff --git a/src/client/extensionActivation.ts b/src/client/extensionActivation.ts index 543d2d0b7f49..7c582eb63239 100644 --- a/src/client/extensionActivation.ts +++ b/src/client/extensionActivation.ts @@ -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'; @@ -16,6 +16,7 @@ import { IFileSystem } from './common/platform/types'; import { IConfigurationService, IDisposableRegistry, + IExperimentService, IExtensions, IInterpreterPathService, ILogOutputChannel, @@ -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. @@ -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); + commands.executeCommand('setContext', 'pythonRunREPL', false); + if (experimentService) { + const replExperimentValue = experimentService.inExperimentSync(EnableRunREPL.experiment); + if (replExperimentValue) { + registerReplCommands(ext.disposables, interpreterService); + commands.executeCommand('setContext', 'pythonRunREPL', true); + } + } } /// ////////////////////////// diff --git a/src/client/repl/pythonServer.ts b/src/client/repl/pythonServer.ts new file mode 100644 index 000000000000..e25ba3a25092 --- /dev/null +++ b/src/client/repl/pythonServer.ts @@ -0,0 +1,81 @@ +import * as path from 'path'; +import * as ch from 'child_process'; +import * as rpc from 'vscode-jsonrpc/node'; +import { Disposable, window } from 'vscode'; +import { EXTENSION_ROOT_DIR } from '../constants'; +import { traceError, traceLog } from '../logging'; + +const SERVER_PATH = path.join(EXTENSION_ROOT_DIR, 'python_files', 'python_server.py'); + +export interface PythonServer extends Disposable { + execute(code: string): Promise; + interrupt(): void; + input(): void; +} + +class PythonServerImpl implements Disposable { + constructor(private connection: rpc.MessageConnection, private pythonServer: ch.ChildProcess) { + this.initialize(); + this.input(); + } + + private initialize(): void { + this.connection.onNotification('log', (message: string) => { + console.log('Log:', message); + }); + this.connection.listen(); + } + + // Register input handler + public input(): void { + // Register input request handler + this.connection.onRequest('input', async (request) => { + // Ask for user input via popup quick input, send it back to Python + let userPrompt = 'Enter your input here: '; + if (request && request.prompt) { + userPrompt = request.prompt; + } + const input = await window.showInputBox({ + title: 'Input Request', + prompt: userPrompt, + ignoreFocusOut: true, + }); + return { userInput: input }; + }); + } + + public execute(code: string): Promise { + return this.connection.sendRequest('execute', code); + } + + public interrupt(): void { + if (this.pythonServer.kill('SIGINT')) { + traceLog('Python server interrupted'); + } + } + + public dispose(): void { + this.connection.sendNotification('exit'); + this.connection.dispose(); + } +} + +export function createPythonServer(interpreter: string[]): PythonServer { + const pythonServer = ch.spawn(interpreter[0], [...interpreter.slice(1), SERVER_PATH]); + + pythonServer.stderr.on('data', (data) => { + traceError(data.toString()); + }); + pythonServer.on('exit', (code) => { + traceError(`Python server exited with code ${code}`); + }); + pythonServer.on('error', (err) => { + traceError(err); + }); + const connection = rpc.createMessageConnection( + new rpc.StreamMessageReader(pythonServer.stdout), + new rpc.StreamMessageWriter(pythonServer.stdin), + ); + + return new PythonServerImpl(connection, pythonServer); +} diff --git a/src/client/repl/replCommands.ts b/src/client/repl/replCommands.ts new file mode 100644 index 000000000000..e7a40b01c6be --- /dev/null +++ b/src/client/repl/replCommands.ts @@ -0,0 +1,104 @@ +import { + commands, + NotebookController, + Uri, + workspace, + window, + NotebookControllerAffinity, + ViewColumn, + NotebookEdit, + NotebookCellData, + NotebookCellKind, + WorkspaceEdit, + NotebookEditor, + TextEditor, +} from 'vscode'; +import { Disposable } from 'vscode-jsonrpc'; +import { Commands, PVSC_EXTENSION_ID } from '../common/constants'; +import { noop } from '../common/utils/misc'; +import { IInterpreterService } from '../interpreter/contracts'; +import { getMultiLineSelectionText, getSingleLineSelectionText } from '../terminals/codeExecution/helper'; +import { createReplController } from './replController'; + +let notebookController: NotebookController | undefined; +let notebookEditor: NotebookEditor | undefined; +// TODO: figure out way to put markdown telling user kernel has been dead and need to pick again. + +async function getSelectedTextToExecute(textEditor: TextEditor): Promise { + if (!textEditor) { + return undefined; + } + + const { selection } = textEditor; + let code: string; + + if (selection.isEmpty) { + code = textEditor.document.lineAt(selection.start.line).text; + } else if (selection.isSingleLine) { + code = getSingleLineSelectionText(textEditor); + } else { + code = getMultiLineSelectionText(textEditor); + } + + return code; +} + +export async function registerReplCommands( + disposables: Disposable[], + interpreterService: IInterpreterService, +): Promise { + disposables.push( + commands.registerCommand(Commands.Exec_In_REPL, async (uri: Uri) => { + const interpreter = await interpreterService.getActiveInterpreter(uri); + if (!interpreter) { + commands.executeCommand(Commands.TriggerEnvironmentSelection, uri).then(noop, noop); + return; + } + if (interpreter) { + const interpreterPath = interpreter.path; + + if (!notebookController) { + notebookController = createReplController(interpreterPath); + } + const activeEditor = window.activeTextEditor as TextEditor; + + const code = await getSelectedTextToExecute(activeEditor); + const ourResource = Uri.from({ scheme: 'untitled', path: 'repl.interactive' }); + + const notebookDocument = await workspace.openNotebookDocument(ourResource); + // commands.executeCommand('_interactive.open'); command to open interactive window so intellisense is registered. + + // We want to keep notebookEditor, whenever we want to run. + // Find interactive window, or open it. + if (!notebookEditor) { + notebookEditor = await window.showNotebookDocument(notebookDocument, { + viewColumn: ViewColumn.Beside, + }); + } + + notebookController!.updateNotebookAffinity(notebookDocument, NotebookControllerAffinity.Default); + + // Auto-Select Python REPL Kernel + await commands.executeCommand('notebook.selectKernel', { + notebookEditor, + id: notebookController?.id, + extension: PVSC_EXTENSION_ID, + }); + + const notebookCellData = new NotebookCellData(NotebookCellKind.Code, code as string, 'python'); + const { cellCount } = notebookDocument; + // Add new cell to interactive window document + const notebookEdit = NotebookEdit.insertCells(cellCount, [notebookCellData]); + const workspaceEdit = new WorkspaceEdit(); + workspaceEdit.set(notebookDocument.uri, [notebookEdit]); + await workspace.applyEdit(workspaceEdit); + + // Execute the cell + commands.executeCommand('notebook.cell.execute', { + ranges: [{ start: cellCount, end: cellCount + 1 }], + document: ourResource, + }); + } + }), + ); +} diff --git a/src/client/repl/replController.ts b/src/client/repl/replController.ts new file mode 100644 index 000000000000..f7ee7e6d486c --- /dev/null +++ b/src/client/repl/replController.ts @@ -0,0 +1,43 @@ +import * as vscode from 'vscode'; +import { createPythonServer } from './pythonServer'; + +export function createReplController(interpreterPath: string): vscode.NotebookController { + const server = createPythonServer([interpreterPath]); + const controller = vscode.notebooks.createNotebookController('pythonREPL', 'interactive', 'Python REPL'); + controller.supportedLanguages = ['python']; + controller.supportsExecutionOrder = true; + + controller.description = 'Python REPL'; + + controller.interruptHandler = async () => { + server.interrupt(); + }; + + controller.executeHandler = async (cells) => { + for (const cell of cells) { + const exec = controller.createNotebookCellExecution(cell); + exec.start(Date.now()); + try { + const result = await server.execute(cell.document.getText()); + + exec.replaceOutput([ + new vscode.NotebookCellOutput([vscode.NotebookCellOutputItem.text(result, 'text/plain')]), + ]); + exec.end(true); + } catch (err) { + const error = err as Error; + exec.replaceOutput([ + new vscode.NotebookCellOutput([ + vscode.NotebookCellOutputItem.error({ + name: error.name, + message: error.message, + stack: error.stack, + }), + ]), + ]); + exec.end(false); + } + } + }; + return controller; +} diff --git a/src/client/terminals/codeExecution/helper.ts b/src/client/terminals/codeExecution/helper.ts index 880da969d690..ff1c4f218f8d 100644 --- a/src/client/terminals/codeExecution/helper.ts +++ b/src/client/terminals/codeExecution/helper.ts @@ -200,7 +200,7 @@ export class CodeExecutionHelper implements ICodeExecutionHelper { } } -function getSingleLineSelectionText(textEditor: TextEditor): string { +export function getSingleLineSelectionText(textEditor: TextEditor): string { const { selection } = textEditor; const selectionRange = new Range(selection.start, selection.end); const selectionText = textEditor.document.getText(selectionRange); @@ -227,7 +227,7 @@ function getSingleLineSelectionText(textEditor: TextEditor): string { return selectionText; } -function getMultiLineSelectionText(textEditor: TextEditor): string { +export function getMultiLineSelectionText(textEditor: TextEditor): string { const { selection } = textEditor; const selectionRange = new Range(selection.start, selection.end); const selectionText = textEditor.document.getText(selectionRange); diff --git a/src/test/terminals/codeExecution/helper.test.ts b/src/test/terminals/codeExecution/helper.test.ts index 2ea00e77c925..9098455c968e 100644 --- a/src/test/terminals/codeExecution/helper.test.ts +++ b/src/test/terminals/codeExecution/helper.test.ts @@ -110,7 +110,9 @@ suite('Terminal - Code Execution Helper', () => { .setup((c) => c.get(TypeMoq.It.isValue(IActiveResourceService))) .returns(() => activeResourceService.object); activeResourceService.setup((a) => a.getActiveResource()).returns(() => resource); - pythonSettings.setup((s) => s.REPL).returns(() => ({ enableREPLSmartSend: false, REPLSmartSend: false })); + pythonSettings + .setup((s) => s.REPL) + .returns(() => ({ enableREPLSmartSend: false, REPLSmartSend: false, enableIWREPL: false })); configurationService.setup((x) => x.getSettings(TypeMoq.It.isAny())).returns(() => pythonSettings.object); configurationService .setup((c) => c.getSettings(TypeMoq.It.isAny())) diff --git a/src/test/terminals/codeExecution/smartSend.test.ts b/src/test/terminals/codeExecution/smartSend.test.ts index f93df2ac11ed..ba5101332bf8 100644 --- a/src/test/terminals/codeExecution/smartSend.test.ts +++ b/src/test/terminals/codeExecution/smartSend.test.ts @@ -107,7 +107,9 @@ suite('REPL - Smart Send', () => { .returns(() => activeResourceService.object); activeResourceService.setup((a) => a.getActiveResource()).returns(() => resource); - pythonSettings.setup((s) => s.REPL).returns(() => ({ enableREPLSmartSend: true, REPLSmartSend: true })); + pythonSettings + .setup((s) => s.REPL) + .returns(() => ({ enableREPLSmartSend: true, REPLSmartSend: true, enableIWREPL: false })); configurationService.setup((x) => x.getSettings(TypeMoq.It.isAny())).returns(() => pythonSettings.object);