Skip to content

Commit

Permalink
python: detect app framework in .py files (#4625)
Browse files Browse the repository at this point in the history
- 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

<!--
  Add additional information for QA on how to validate the change,
  paying special attention to the level of risk, adjacent areas that
  could be affected by the change, and any important contextual
  information not present in the linked issues.
-->


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 <[email protected]>
Co-authored-by: Wasim Lorgat <[email protected]>
  • Loading branch information
isabelizimm and seeM committed Sep 18, 2024
1 parent 73a9b78 commit 6af852f
Show file tree
Hide file tree
Showing 10 changed files with 308 additions and 2 deletions.
84 changes: 84 additions & 0 deletions extensions/positron-python/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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%",
Expand Down Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions extensions/positron-python/package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---
Expand Down
6 changes: 6 additions & 0 deletions extensions/positron-python/src/client/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---
Expand Down
2 changes: 1 addition & 1 deletion extensions/positron-python/src/client/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ export async function activate(context: IExtensionContext): Promise<PythonExtens

// --- Start Positron ---

activatePositron(serviceContainer)
activatePositron(serviceContainer, context)
// Run in the background.
.ignoreErrors();

Expand Down
10 changes: 9 additions & 1 deletion extensions/positron-python/src/client/positron/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,12 @@ import { EnvironmentType } from '../pythonEnvironments/info';
import { isProblematicCondaEnvironment } from '../interpreter/configuration/environmentTypeComparer';
import { Interpreters } from '../common/utils/localize';
import { IApplicationShell } from '../common/application/types';
import { activateAppDetection } from './webAppContexts';

export async function activatePositron(serviceContainer: IServiceContainer): Promise<void> {
export async function activatePositron(
serviceContainer: IServiceContainer,
context: vscode.ExtensionContext,
): Promise<void> {
try {
const disposables = serviceContainer.get<IDisposableRegistry>(IDisposableRegistry);
// Register a command to check if ipykernel is installed for a given interpreter.
Expand Down Expand Up @@ -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);
Expand Down
91 changes: 91 additions & 0 deletions extensions/positron-python/src/client/positron/webAppContexts.ts
Original file line number Diff line number Diff line change
@@ -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();
}
}),
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Loading

0 comments on commit 6af852f

Please sign in to comment.