From 6af852f4b407037270f7878f9243079e22cb1094 Mon Sep 17 00:00:00 2001 From: Isabel Zimmerman <54685329+isabelizimm@users.noreply.github.com> Date: Wed, 18 Sep 2024 14:01:58 -0400 Subject: [PATCH] python: detect app framework in .py files (#4625) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - creates context key for is/is not app - "Run App in Terminal" will show up when in an app We might want to think about how this interacts with the Shiny extension. I guess at this point, it will just populate a LOT of options in the Play button, which is ugly but benign. ### QA Notes create a `.py` file ``` import streamlit as st x = st.slider('x') # 👈 this is a widget st.write(x, 'squared is', x * x) ``` open and click on play button, you should see ![Screenshot 2024-09-12 at 4 01 23 PM](https://github.com/user-attachments/assets/afacee31-e6cc-48d8-b185-1adcfd3bf95d) (clicking on this file will only save the file and have no other affect) --------- Signed-off-by: Isabel Zimmerman <54685329+isabelizimm@users.noreply.github.com> Co-authored-by: Wasim Lorgat --- extensions/positron-python/package.json | 84 +++++++++++++++++ extensions/positron-python/package.nls.json | 7 ++ .../src/client/common/application/commands.ts | 6 ++ .../src/client/common/constants.ts | 6 ++ .../positron-python/src/client/extension.ts | 2 +- .../src/client/positron/extension.ts | 10 +- .../src/client/positron/webAppContexts.ts | 91 +++++++++++++++++++ .../codeExecution/codeExecutionManager.ts | 36 ++++++++ .../test/positron/webAppContexts.unit.test.ts | 62 +++++++++++++ .../codeExecutionManager.unit.test.ts | 6 ++ 10 files changed, 308 insertions(+), 2 deletions(-) create mode 100644 extensions/positron-python/src/client/positron/webAppContexts.ts create mode 100644 extensions/positron-python/src/test/positron/webAppContexts.unit.test.ts diff --git a/extensions/positron-python/package.json b/extensions/positron-python/package.json index 119a090e8b2..5007a718c2e 100644 --- a/extensions/positron-python/package.json +++ b/extensions/positron-python/package.json @@ -300,6 +300,48 @@ "icon": "$(play)", "title": "%python.command.python.execInTerminalIcon.title%" }, + { + "category": "Python", + "command": "python.execDashInTerminal", + "icon": "$(play)", + "title": "%python.command.python.execDashInTerminal.title%", + "enablement": "pythonAppFramework == dash && config.python.enableWebAppSupport" + }, + { + "category": "Python", + "command": "python.execFastAPIInTerminal", + "icon": "$(play)", + "title": "%python.command.python.execFastAPIInTerminal.title%", + "enablement": "pythonAppFramework == fastapi && config.python.enableWebAppSupport" + }, + { + "category": "Python", + "command": "python.execFlaskInTerminal", + "icon": "$(play)", + "title": "%python.command.python.execFlaskInTerminal.title%", + "enablement": "pythonAppFramework == flask && config.python.enableWebAppSupport" + }, + { + "category": "Python", + "command": "python.execGradioInTerminal", + "icon": "$(play)", + "title": "%python.command.python.execGradioInTerminal.title%", + "enablement": "pythonAppFramework == gradio && config.python.enableWebAppSupport" + }, + { + "category": "Python", + "command": "python.execShinyInTerminal", + "icon": "$(play)", + "title": "%python.command.python.execShinyInTerminal.title%", + "enablement": "pythonAppFramework == shiny && config.python.enableWebAppSupport" + }, + { + "category": "Python", + "command": "python.execStreamlitInTerminal", + "icon": "$(play)", + "title": "%python.command.python.execStreamlitInTerminal.title%", + "enablement": "pythonAppFramework == streamlit && config.python.enableWebAppSupport" + }, { "category": "Python", "command": "python.execInConsole", @@ -440,6 +482,12 @@ "scope": "application", "type": "boolean" }, + "python.enableWebAppSupport": { + "defaut": false, + "description": "%python.enableWebAppSupport.description%", + "scope": "application", + "type": "boolean" + }, "python.envFile": { "default": "${workspaceFolder}/.env", "description": "%python.envFile.description%", @@ -1479,6 +1527,42 @@ "title": "%python.command.python.execInConsole.title%", "when": "resourceLangId == python && !isInDiffEditor && !virtualWorkspace && shellExecutionSupported" }, + { + "command": "python.execDashInTerminal", + "group": "navigation@1", + "title": "%python.command.python.execDashInTerminal.title%", + "when": "pythonAppFramework == dash && config.python.enableWebAppSupport" + }, + { + "command": "python.execGradioInTerminal", + "group": "navigation@1", + "title": "%python.command.python.execGradioInTerminal.title%", + "when": "pythonAppFramework == gradio && config.python.enableWebAppSupport" + }, + { + "command": "python.execFastAPIInTerminal", + "group": "navigation@1", + "title": "%python.command.python.execFastAPIInTerminal.title%", + "when": "pythonAppFramework == fastapi && config.python.enableWebAppSupport" + }, + { + "command": "python.execFlaskInTerminal", + "group": "navigation@1", + "title": "%python.command.python.execFlaskInTerminal.title%", + "when": "pythonAppFramework == flask && config.python.enableWebAppSupport" + }, + { + "command": "python.execShinyInTerminal", + "group": "navigation@1", + "title": "%python.command.python.execShinyInTerminal.title%", + "when": "pythonAppFramework == shiny && config.python.enableWebAppSupport" + }, + { + "command": "python.execStreamlitInTerminal", + "group": "navigation@1", + "title": "%python.command.python.execStreamlitInTerminal.title%", + "when": "pythonAppFramework == streamlit && config.python.enableWebAppSupport" + }, { "command": "python.execInTerminal-icon", "group": "navigation@1", diff --git a/extensions/positron-python/package.nls.json b/extensions/positron-python/package.nls.json index 0e04ca2e541..f8982c9ac48 100644 --- a/extensions/positron-python/package.nls.json +++ b/extensions/positron-python/package.nls.json @@ -6,6 +6,12 @@ "python.command.python.createNewFile.title": "New Python File", "python.command.python.createTerminal.title": "Create Terminal", "python.command.python.execInTerminal.title": "Run Python File in Terminal", + "python.command.python.execDashInTerminal.title": "Run Dash App in Terminal", + "python.command.python.execFastAPIInTerminal.title": "Run FastAPI in Terminal", + "python.command.python.execFlaskInTerminal.title": "Run Flask App in Terminal", + "python.command.python.execGradioInTerminal.title": "Run Gradio App in Terminal", + "python.command.python.execShinyInTerminal.title": "Run Shiny App in Terminal", + "python.command.python.execStreamlitInTerminal.title": "Run Streamlit App in Terminal", "python.command.python.execInConsole.title": "Run Python File in Console", "python.command.python.debugInTerminal.title": "Debug Python File in Terminal", "python.command.python.execInTerminalIcon.title": "Run Python File in Terminal", @@ -38,6 +44,7 @@ "python.debugger.deprecatedMessage": "This configuration will be deprecated soon. Please replace `python` with `debugpy` to use the new Python Debugger extension.", "python.defaultInterpreterPath.description": "Path to default Python to use when extension loads up for the first time, no longer used once an interpreter is selected for the workspace. See [here](https://aka.ms/AAfekmf) to understand when this is used", "python.diagnostics.sourceMapsEnabled.description": "Enable source map support for meaningful stack traces in error logs.", + "python.enableWebAppSupport.description": "Enable experimental support for Python applications", "python.envFile.description": "Absolute path to a file containing environment variable definitions.", "python.experiments.enabled.description": "Enables A/B tests experiments in the Python extension. If enabled, you may get included in proposed enhancements and/or features.", "python.experiments.optInto.description": "List of experiment to opt into. If empty, user is assigned the default experiment groups. See [here](https://github.com/microsoft/vscode-python/wiki/AB-Experiments) for more details.", diff --git a/extensions/positron-python/src/client/common/application/commands.ts b/extensions/positron-python/src/client/common/application/commands.ts index 10525b4bead..babc0c5e743 100644 --- a/extensions/positron-python/src/client/common/application/commands.ts +++ b/extensions/positron-python/src/client/common/application/commands.ts @@ -102,6 +102,12 @@ export interface ICommandNameArgumentTypeMapping extends ICommandNameWithoutArgu [Commands.Exec_In_Terminal_Icon]: [undefined, Uri]; [Commands.Debug_In_Terminal]: [Uri]; // --- Start Positron --- + [Commands.Exec_Dash_In_Terminal]: [undefined, Uri]; + [Commands.Exec_FastAPI_In_Terminal]: [undefined, Uri]; + [Commands.Exec_Flask_In_Terminal]: [undefined, Uri]; + [Commands.Exec_Gradio_In_Terminal]: [undefined, Uri]; + [Commands.Exec_Shiny_In_Terminal]: [undefined, Uri]; + [Commands.Exec_Streamlit_In_Terminal]: [undefined, Uri]; [Commands.Exec_In_Console]: []; [Commands.Focus_Positron_Console]: []; // --- End Positron --- diff --git a/extensions/positron-python/src/client/common/constants.ts b/extensions/positron-python/src/client/common/constants.ts index 718890ef612..b45e73575a5 100644 --- a/extensions/positron-python/src/client/common/constants.ts +++ b/extensions/positron-python/src/client/common/constants.ts @@ -52,6 +52,12 @@ export namespace Commands { export const Exec_In_Terminal_Icon = 'python.execInTerminal-icon'; export const Exec_In_Separate_Terminal = 'python.execInDedicatedTerminal'; // --- Start Positron --- + export const Exec_Dash_In_Terminal = 'python.execDashInTerminal'; + export const Exec_FastAPI_In_Terminal = 'python.execFastAPIInTerminal'; + export const Exec_Flask_In_Terminal = 'python.execFlaskInTerminal'; + export const Exec_Gradio_In_Terminal = 'python.execGradioInTerminal'; + export const Exec_Shiny_In_Terminal = 'python.execShinyInTerminal'; + export const Exec_Streamlit_In_Terminal = 'python.execStreamlitInTerminal'; export const Exec_In_Console = 'python.execInConsole'; export const Exec_Selection_In_Console = 'python.execSelectionInConsole'; // --- End Positron --- diff --git a/extensions/positron-python/src/client/extension.ts b/extensions/positron-python/src/client/extension.ts index 3736954fbf0..05754662e74 100644 --- a/extensions/positron-python/src/client/extension.ts +++ b/extensions/positron-python/src/client/extension.ts @@ -91,7 +91,7 @@ export async function activate(context: IExtensionContext): Promise { +export async function activatePositron( + serviceContainer: IServiceContainer, + context: vscode.ExtensionContext, +): Promise { try { const disposables = serviceContainer.get(IDisposableRegistry); // Register a command to check if ipykernel is installed for a given interpreter. @@ -71,6 +75,10 @@ export async function activatePositron(serviceContainer: IServiceContainer): Pro disposables.push( vscode.commands.registerCommand('python.getMinimumPythonVersion', (): string => MINIMUM_PYTHON_VERSION.raw), ); + + // Activate detection for web applications + activateAppDetection(context.subscriptions); + traceInfo('activatePositron: done!'); } catch (ex) { traceError('activatePositron() failed.', ex); diff --git a/extensions/positron-python/src/client/positron/webAppContexts.ts b/extensions/positron-python/src/client/positron/webAppContexts.ts new file mode 100644 index 00000000000..faa0280a11b --- /dev/null +++ b/extensions/positron-python/src/client/positron/webAppContexts.ts @@ -0,0 +1,91 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2024 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { executeCommand } from '../common/vscodeApis/commandApis'; + +const libraries: string[] = ['streamlit', 'shiny', 'dash', 'gradio', 'flask', 'fastapi']; + +export function detectWebApp(document: vscode.TextDocument): void { + const text = document.getText(); + const framework = getFramework(text); + executeCommand('setContext', 'pythonAppFramework', framework); +} + +export function getFramework(text: string): string | undefined { + const importPattern = new RegExp(`import\\s+(${libraries.join('|')})`, 'g'); + const fromImportPattern = new RegExp(`from\\s+(${libraries.join('|')})\\S*\\simport`, 'g'); + const importMatch = importPattern.exec(text); + + if (importMatch) { + return importMatch[1]; + } + + const fromImportMatch = fromImportPattern.exec(text); + if (fromImportMatch) { + return fromImportMatch[1]; + } + + return undefined; +} + +export function activateAppDetection(disposables: vscode.Disposable[]): void { + let timeout: NodeJS.Timeout | undefined; + let activeEditor = vscode.window.activeTextEditor; + + function updateWebApp() { + if (!activeEditor) { + return; + } + detectWebApp(activeEditor.document); + } + + // Throttle updates if needed + function triggerUpdateApp(throttle = false) { + if (!activeEditor) { + return; + } + if (timeout) { + clearTimeout(timeout); + timeout = undefined; + } + if (throttle) { + timeout = setTimeout(updateWebApp, 500); + } else { + detectWebApp(activeEditor.document); + } + } + + // Trigger for the current active editor. + if (activeEditor) { + triggerUpdateApp(); + } + + disposables.push( + // Trigger when the active editor changes + vscode.window.onDidChangeActiveTextEditor((editor) => { + if (editor && editor.document.languageId === 'python') { + activeEditor = editor; + triggerUpdateApp(); + } + }), + + // Trigger when the active editor's content changes + vscode.workspace.onDidChangeTextDocument((event) => { + if (activeEditor && event.document === activeEditor.document) { + triggerUpdateApp(true); + } + }), + + // Trigger when new text document is opened + vscode.workspace.onDidOpenTextDocument((document) => { + if (document.languageId === 'python') { + // update to opened text document + activeEditor = vscode.window.activeTextEditor; + triggerUpdateApp(); + } + }), + ); +} diff --git a/extensions/positron-python/src/client/terminals/codeExecution/codeExecutionManager.ts b/extensions/positron-python/src/client/terminals/codeExecution/codeExecutionManager.ts index 8abd73565bb..13dafd54612 100644 --- a/extensions/positron-python/src/client/terminals/codeExecution/codeExecutionManager.ts +++ b/extensions/positron-python/src/client/terminals/codeExecution/codeExecutionManager.ts @@ -26,6 +26,9 @@ import { CreateEnvironmentCheckKind, triggerCreateEnvironmentCheckNonBlocking, } from '../../pythonEnvironments/creation/createEnvironmentTrigger'; +// --- Start Positron --- +//import { getAppFramework } from '../../positron/webAppContexts' +// --- End Positron --- @injectable() export class CodeExecutionManager implements ICodeExecutionManager { @@ -75,6 +78,39 @@ export class CodeExecutionManager implements ICodeExecutionManager { }, ); // --- Start Positron --- + [ + Commands.Exec_Streamlit_In_Terminal, + Commands.Exec_Dash_In_Terminal, + Commands.Exec_FastAPI_In_Terminal, + Commands.Exec_Flask_In_Terminal, + Commands.Exec_Gradio_In_Terminal, + Commands.Exec_Shiny_In_Terminal, + ].forEach((cmd) => { + this.disposableRegistry.push( + this.commandManager.registerCommand(cmd as any, async () => { + // use editor to get contents of file + const editor = vscode.window.activeTextEditor; + if (!editor) { + // No editor; nothing to do + return; + } + + const filePath = editor.document.uri.fsPath; + if (!filePath) { + // File is unsaved; show a warning + vscode.window.showWarningMessage('Cannot run unsaved file.'); + return; + } + + // Save the file before sourcing it to ensure that the contents are + // up to date with editor buffer. + await vscode.commands.executeCommand('workbench.action.files.save'); + + // TODO: connect appFramework to commands to run script + //const appFramework = getAppFramework(editor.document.getText()) + }), + ); + }); this.disposableRegistry.push( this.commandManager.registerCommand(Commands.Exec_In_Console as any, async () => { // Get the active text editor. diff --git a/extensions/positron-python/src/test/positron/webAppContexts.unit.test.ts b/extensions/positron-python/src/test/positron/webAppContexts.unit.test.ts new file mode 100644 index 00000000000..18dd7caccf3 --- /dev/null +++ b/extensions/positron-python/src/test/positron/webAppContexts.unit.test.ts @@ -0,0 +1,62 @@ +import * as vscode from 'vscode'; +import * as sinon from 'sinon'; +import { assert } from 'chai'; +import * as cmdApis from '../../client/common/vscodeApis/commandApis'; +import { detectWebApp, getFramework } from '../../client/positron/webAppContexts'; +import { IDisposableRegistry } from '../../client/common/types'; + +suite('Discover webapp frameworks', () => { + let document: vscode.TextDocument; + let executeCommandStub: sinon.SinonStub; + const disposables: IDisposableRegistry = []; + + setup(() => { + executeCommandStub = sinon.stub(cmdApis, 'executeCommand'); + document = { + getText: () => '', + } as vscode.TextDocument; + }); + + teardown(() => { + sinon.restore(); + disposables.forEach((d) => d.dispose()); + }); + + const texts = { + 'import streamlit': 'streamlit', + 'from shiny.ui import page_navbar': 'shiny', + 'import numpy': 'numpy', + }; + Object.entries(texts).forEach(([text, framework]) => { + const expected = text.includes('numpy') ? undefined : framework; + test('should set context pythonAppFramework if application is found', () => { + document.getText = () => text; + detectWebApp(document); + + assert.ok(executeCommandStub.calledOnceWith('setContext', 'pythonAppFramework', expected)); + }); + }); + + const frameworks = ['streamlit', 'shiny', 'gradio', 'flask', 'fastapi', 'numpy']; + frameworks.forEach((framework) => { + const expected = framework === 'numpy' ? undefined : framework; + test(`should detect ${expected}: import framework`, () => { + const text = `import ${framework}`; + const actual = getFramework(text); + + assert.strictEqual(actual, expected); + }); + test(`should detect ${expected}: from framework.test import XYZ`, () => { + const text = `from ${framework}.test import XYZ`; + const actual = getFramework(text); + + assert.strictEqual(actual, expected); + }); + test(`should detect ${expected}: from framework import XYZ`, () => { + const text = `from ${framework} import XYZ`; + const actual = getFramework(text); + + assert.strictEqual(actual, expected); + }); + }); +}); diff --git a/extensions/positron-python/src/test/terminals/codeExecution/codeExecutionManager.unit.test.ts b/extensions/positron-python/src/test/terminals/codeExecution/codeExecutionManager.unit.test.ts index 55eb51d1c31..103be1fd61c 100644 --- a/extensions/positron-python/src/test/terminals/codeExecution/codeExecutionManager.unit.test.ts +++ b/extensions/positron-python/src/test/terminals/codeExecution/codeExecutionManager.unit.test.ts @@ -90,6 +90,12 @@ suite('Terminal - Code Execution Manager', () => { [ // --- Start Positron --- // Add the Positron execute in console command and execute selection in console command. + Commands.Exec_Dash_In_Terminal, + Commands.Exec_FastAPI_In_Terminal, + Commands.Exec_Flask_In_Terminal, + Commands.Exec_Gradio_In_Terminal, + Commands.Exec_Shiny_In_Terminal, + Commands.Exec_Streamlit_In_Terminal, Commands.Exec_In_Console, Commands.Exec_Selection_In_Console, // --- End Positron ---