Skip to content

Commit

Permalink
improve Run App url matching and fix preview timeout issue (#5349)
Browse files Browse the repository at this point in the history
Cherry-picks #5336 to bring
the fix from the 2024.11 patch branch to the `main` branch. The below
description is copied from #5336 and modified to remove the AppUrlString
type changes due to conflicts with Prettier.

The prerelease branch will need to be updated again to include the
additional changes.

### Addresses
- #5197
- #5306

### Implementation Notes

#### URL Matching
- introduces `appUrlStrings` to the run and debug app options, which we
will attempt to extract the app url from
- the strings should contain the placeholder `{{APP_URL}}`, which
indicates the location of the app url relative to the string
- adds the appropriate `appUrlStrings` for each framework we support
- expands on our url matching by:
- attempting to match url-like text where `{{APP_URL}}` is found in the
provided `appUrlStrings`
- if matching against `appUrlStrings` fails or no `appUrlStrings` are
found, we fallback to a more basic url match for strings that start with
http or https

#### Shell Integration Warning Message
This PR should also fix a timing issue where the `didPreviewUrlTimeout`
would time out before `terminalOutputTimeout`, causing the shell
integration warning message to show. We now set `didPreviewUrlTimeout`
to be 5 seconds longer than `terminalOutputTimeout`, so that it doesn't
timeout before the app preview is done.

### QA Notes

This PR fixes Run App in Terminal url detection for non-local URLs. One
way to get a non-local url is by running the `shiny-py-example` in
Positron on Workbench (see #5197).

Other app types like Dash, Streamlit, Fastapi, Flask and Gradio should
continue to work on Desktop, Server Web and Positron on Workbench.

This PR should also fix the issue seen in #5306, which can be tested by
running the [Dash Py Example](https://github.com/posit-dev/qa-example-content/tree/main/workspaces/dash-py-example) several times to check that the issue does not occur.
  • Loading branch information
sharon-wang authored Nov 13, 2024
1 parent fbd8b46 commit 461485e
Show file tree
Hide file tree
Showing 4 changed files with 167 additions and 24 deletions.
10 changes: 10 additions & 0 deletions extensions/positron-python/src/client/positron-run-app.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ export interface RunAppOptions {
* The optional app ready message to wait for in the terminal before previewing the application.
*/
appReadyMessage?: string;

/**
* An optional array of app URI formats to parse the URI from the terminal output.
*/
appUrlStrings?: string[];
}

/**
Expand Down Expand Up @@ -88,6 +93,11 @@ export interface DebugAppOptions {
* The optional app ready message to wait for in the terminal before previewing the application.
*/
appReadyMessage?: string;

/**
* An optional array of app URI formats to parse the URI from the terminal output.
*/
appUrlStrings?: string[];
}

/**
Expand Down
92 changes: 76 additions & 16 deletions extensions/positron-python/src/client/positron/webAppCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,57 +16,113 @@ import { Commands } from '../common/constants';

export function activateWebAppCommands(serviceContainer: IServiceContainer, disposables: vscode.Disposable[]): void {
disposables.push(
registerExecCommand(Commands.Exec_Dash_In_Terminal, 'Dash', (_runtime, document, urlPrefix) =>
getDashDebugConfig(document, urlPrefix),
registerExecCommand(
Commands.Exec_Dash_In_Terminal,
'Dash',
(_runtime, document, urlPrefix) => getDashDebugConfig(document, urlPrefix),
undefined,
undefined,
// Dash url string: https://github.com/plotly/dash/blob/95665785f184aba4ce462637c30ccb7789280911/dash/dash.py#L2160
['Dash is running on {{APP_URL}}'],
),
registerExecCommand(
Commands.Exec_FastAPI_In_Terminal,
'FastAPI',
(runtime, document, _urlPrefix) => getFastAPIDebugConfig(serviceContainer, runtime, document),
'/docs',
'Application startup complete',
// Uvicorn url string: https://github.com/encode/uvicorn/blob/fe3910083e3990695bc19c2ef671dd447262ae18/uvicorn/config.py#L479-L525
['Uvicorn running on {{APP_URL}}'],
),
registerExecCommand(Commands.Exec_Flask_In_Terminal, 'Flask', (_runtime, document, _urlPrefix) =>
getFlaskDebugConfig(document),
registerExecCommand(
Commands.Exec_Flask_In_Terminal,
'Flask',
(_runtime, document, _urlPrefix) => getFlaskDebugConfig(document),
undefined,
undefined,
// Flask url string is from Werkzeug: https://github.com/pallets/werkzeug/blob/7868bef5d978093a8baa0784464ebe5d775ae92a/src/werkzeug/serving.py#L833-L865
['Running on {{APP_URL}}'],
),
registerExecCommand(Commands.Exec_Gradio_In_Terminal, 'Gradio', (_runtime, document, urlPrefix) =>
getGradioDebugConfig(document, urlPrefix),
registerExecCommand(
Commands.Exec_Gradio_In_Terminal,
'Gradio',
(_runtime, document, urlPrefix) => getGradioDebugConfig(document, urlPrefix),
undefined,
undefined,
// Gradio url strings: https://github.com/gradio-app/gradio/blob/main/gradio/strings.py
['Running on local URL: {{APP_URL}}', 'Running on public URL: {{APP_URL}}'],
),
registerExecCommand(
Commands.Exec_Shiny_In_Terminal,
'Shiny',
(_runtime, document, _urlPrefix) => getShinyDebugConfig(document),
undefined,
'Application startup complete',
// Uvicorn url string: https://github.com/encode/uvicorn/blob/fe3910083e3990695bc19c2ef671dd447262ae18/uvicorn/config.py#L479-L525
['Uvicorn running on {{APP_URL}}'],
),
registerExecCommand(Commands.Exec_Streamlit_In_Terminal, 'Streamlit', (_runtime, document, _urlPrefix) =>
getStreamlitDebugConfig(document),
registerExecCommand(
Commands.Exec_Streamlit_In_Terminal,
'Streamlit',
(_runtime, document, _urlPrefix) => getStreamlitDebugConfig(document),
undefined,
undefined,
// Streamlit url string: https://github.com/streamlit/streamlit/blob/3e6248461cce366b1f54c273e787adf84a66148d/lib/streamlit/web/bootstrap.py#L197
['Local URL: {{APP_URL}}'],
),
registerDebugCommand(Commands.Debug_Dash_In_Terminal, 'Dash', (_runtime, document, urlPrefix) =>
getDashDebugConfig(document, urlPrefix),
registerDebugCommand(
Commands.Debug_Dash_In_Terminal,
'Dash',
(_runtime, document, urlPrefix) => getDashDebugConfig(document, urlPrefix),
undefined,
undefined,
// Dash url string: https://github.com/plotly/dash/blob/95665785f184aba4ce462637c30ccb7789280911/dash/dash.py#L2160
['Dash is running on {{APP_URL}}'],
),
registerDebugCommand(
Commands.Debug_FastAPI_In_Terminal,
'FastAPI',
(runtime, document, _urlPrefix) => getFastAPIDebugConfig(serviceContainer, runtime, document),
'/docs',
'Application startup complete',
// Uvicorn url string: https://github.com/encode/uvicorn/blob/fe3910083e3990695bc19c2ef671dd447262ae18/uvicorn/config.py#L479-L525
['Uvicorn running on {{APP_URL}}'],
),
registerDebugCommand(Commands.Debug_Flask_In_Terminal, 'Flask', (_runtime, document, _urlPrefix) =>
getFlaskDebugConfig(document),
registerDebugCommand(
Commands.Debug_Flask_In_Terminal,
'Flask',
(_runtime, document, _urlPrefix) => getFlaskDebugConfig(document),
undefined,
undefined,
// Flask url string is from Werkzeug: https://github.com/pallets/werkzeug/blob/7868bef5d978093a8baa0784464ebe5d775ae92a/src/werkzeug/serving.py#L833-L865
['Running on {{APP_URL}}'],
),
registerDebugCommand(Commands.Debug_Gradio_In_Terminal, 'Gradio', (_runtime, document, urlPrefix) =>
getGradioDebugConfig(document, urlPrefix),
registerDebugCommand(
Commands.Debug_Gradio_In_Terminal,
'Gradio',
(_runtime, document, urlPrefix) => getGradioDebugConfig(document, urlPrefix),
undefined,
undefined,
// Gradio url strings: https://github.com/gradio-app/gradio/blob/main/gradio/strings.py
['Running on local URL: {{APP_URL}}', 'Running on public URL: {{APP_URL}}'],
),
registerDebugCommand(
Commands.Debug_Shiny_In_Terminal,
'Shiny',
(_runtime, document, _urlPrefix) => getShinyDebugConfig(document),
undefined,
'Application startup complete',
// Uvicorn url string: https://github.com/encode/uvicorn/blob/fe3910083e3990695bc19c2ef671dd447262ae18/uvicorn/config.py#L479-L525
['Uvicorn running on {{APP_URL}}'],
),
registerDebugCommand(Commands.Debug_Streamlit_In_Terminal, 'Streamlit', (_runtime, document, _urlPrefix) =>
getStreamlitDebugConfig(document),
registerDebugCommand(
Commands.Debug_Streamlit_In_Terminal,
'Streamlit',
(_runtime, document, _urlPrefix) => getStreamlitDebugConfig(document),
undefined,
undefined,
// Streamlit url string: https://github.com/streamlit/streamlit/blob/3e6248461cce366b1f54c273e787adf84a66148d/lib/streamlit/web/bootstrap.py#L197
['Local URL: {{APP_URL}}'],
),
);
}
Expand All @@ -81,6 +137,7 @@ function registerExecCommand(
) => DebugConfiguration | undefined | Promise<DebugConfiguration | undefined>,
urlPath?: string,
appReadyMessage?: string,
appUrlStrings?: string[],
): vscode.Disposable {
return vscode.commands.registerCommand(command, async () => {
const runAppApi = await getPositronRunAppApi();
Expand Down Expand Up @@ -113,6 +170,7 @@ function registerExecCommand(
},
urlPath,
appReadyMessage,
appUrlStrings,
});
});
}
Expand All @@ -127,6 +185,7 @@ function registerDebugCommand(
) => DebugConfiguration | undefined | Promise<DebugConfiguration | undefined>,
urlPath?: string,
appReadyMessage?: string,
appUrlStrings?: string[],
): vscode.Disposable {
return vscode.commands.registerCommand(command, async () => {
const runAppApi = await getPositronRunAppApi();
Expand All @@ -148,6 +207,7 @@ function registerDebugCommand(
},
urlPath,
appReadyMessage,
appUrlStrings,
});
});
}
Expand Down
79 changes: 71 additions & 8 deletions extensions/positron-run-app/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,22 @@ import { Config, log } from './extension';
import { DebugAppOptions, PositronRunApp, RunAppOptions } from './positron-run-app';
import { raceTimeout, removeAnsiEscapeCodes, SequencerByKey } from './utils';

// Regex to match a URL with the format http://localhost:1234/path
const localUrlRegex = /http:\/\/(localhost|127\.0\.0\.1):(\d{1,5})(\/[^\s]*)?/;
// Regex to match a string that starts with http:// or https://.
const httpUrlRegex = /((https?:\/\/)([a-zA-Z0-9.-]+)(:\d{1,5})?(\/[^\s]*)?)/;
// A more permissive URL regex to be used when a string containing an {{APP_URL}} placeholder is expected.
const urlLikeRegex = /((https?:\/\/)?([a-zA-Z0-9.-]*[.:][a-zA-Z0-9.-]*)(:\d{1,5})?(\/[^\s]*)?)/;

// App URL Placeholder string.
const APP_URL_PLACEHOLDER = '{{APP_URL}}';

// Flags to determine where Positron is running.
const isPositronWeb = vscode.env.uiKind === vscode.UIKind.Web;
const isRunningOnPwb = !!process.env.RS_SERVER_URL && isPositronWeb;

// Timeouts.
const terminalOutputTimeout = 25_000;
const didPreviewUrlTimeout = terminalOutputTimeout + 5_000;

type PositronProxyInfo = {
proxyPath: string;
externalUri: vscode.Uri;
Expand All @@ -27,6 +37,7 @@ type AppPreviewOptions = {
proxyInfo?: PositronProxyInfo;
urlPath?: string;
appReadyMessage?: string;
appUrlStrings?: string[];
};

export class PositronRunAppApiImpl implements PositronRunApp, vscode.Disposable {
Expand Down Expand Up @@ -172,6 +183,7 @@ export class PositronRunAppApiImpl implements PositronRunApp, vscode.Disposable
proxyInfo,
urlPath: options.urlPath,
appReadyMessage: options.appReadyMessage,
appUrlStrings: options.appUrlStrings,
};
const didPreviewUrl = await previewUrlInExecutionOutput(e.execution, previewOptions);
if (didPreviewUrl) {
Expand All @@ -181,7 +193,7 @@ export class PositronRunAppApiImpl implements PositronRunApp, vscode.Disposable
});
this._runApplicationDisposableByName.set(options.name, disposable);
}),
10_000,
didPreviewUrlTimeout,
async () => {
await this.setShellIntegrationSupported(false);
});
Expand Down Expand Up @@ -294,6 +306,7 @@ export class PositronRunAppApiImpl implements PositronRunApp, vscode.Disposable
proxyInfo,
urlPath: options.urlPath,
appReadyMessage: options.appReadyMessage,
appUrlStrings: options.appUrlStrings,
};
const didPreviewUrl = await previewUrlInExecutionOutput(e.execution, previewOptions);
if (didPreviewUrl) {
Expand All @@ -305,7 +318,7 @@ export class PositronRunAppApiImpl implements PositronRunApp, vscode.Disposable
});
this._debugApplicationDisposableByName.set(options.name, disposable);
}),
10_000,
didPreviewUrlTimeout,
async () => {
await this.setShellIntegrationSupported(false);
});
Expand Down Expand Up @@ -386,12 +399,11 @@ async function previewUrlInExecutionOutput(execution: vscode.TerminalShellExecut
}
}
}

// Check if the app url is found in the terminal output.
if (!appUrl) {
const match = dataCleaned.match(localUrlRegex)?.[0];
const match = extractAppUrlFromString(dataCleaned, options.appUrlStrings);
if (match) {
appUrl = new URL(match.trim());
appUrl = new URL(match);
log.debug(`Found app URL in terminal output: ${appUrl.toString()}`);
// If the app is ready, we're done!
if (appReady) {
Expand All @@ -413,7 +425,7 @@ async function previewUrlInExecutionOutput(execution: vscode.TerminalShellExecut
}
return appUrl;
})(),
15_000,
terminalOutputTimeout,
() => log.error('Timed out waiting for server output in terminal'),
);

Expand Down Expand Up @@ -554,3 +566,54 @@ function shouldUsePositronProxy(appName: string) {
return true;
}
}

/**
* Extracts a URL from a string using the provided appUrlStrings.
* @param str The string to match the URL in.
* @param appUrlStrings An array of app url strings to match and extract the URL from.
* @returns The matched URL, or undefined if no URL is found.
*/
function extractAppUrlFromString(str: string, appUrlStrings?: string[]) {
if (appUrlStrings && appUrlStrings.length > 0) {
// Try to match any of the provided appUrlStrings.
log.debug('Attempting to match URL with:', appUrlStrings);
for (const appUrlString of appUrlStrings) {
if (!appUrlString.includes(APP_URL_PLACEHOLDER)) {
log.warn(`Skipping '${appUrlString}' since it doesn't contain an ${APP_URL_PLACEHOLDER} placeholder.`);
continue;
}

const pattern = appUrlString.replace(APP_URL_PLACEHOLDER, urlLikeRegex.source);
const appUrlRegex = new RegExp(pattern);

const match = str.match(appUrlRegex);
if (match) {
const endsWithAppUrl = appUrlString.endsWith(APP_URL_PLACEHOLDER);
// Placeholder is at the end of the string. This is the most common case.
// Example: 'The app is running at {{APP_URL}}'
// [0] = 'The app is running at ', [1] = '{{APP_URL}}'
// Also covers the case where the placeholder is the entire string.
if (endsWithAppUrl) {
return match[1];
}

const startsWithAppUrl = appUrlString.startsWith(APP_URL_PLACEHOLDER);
// Placeholder is at the start of the string.
// Example: '{{APP_URL}} is where the app is running'
// [0] = '{{APP_URL}}', [1] = ' is where the app is running'
if (startsWithAppUrl) {
return match[0];
}

// Placeholder is in the middle of the string.
// Example: 'Open {{APP_URL}} to view the app'
// [0] = 'Open ', [1] = '{{APP_URL}}', [2] = ' to view the app'
return match[1];
}
}
}

// Fall back to the default URL regex if no appUrlStrings were provided or matched.
log.debug('No appUrlStrings matched. Falling back to default URL regex to match URL.');
return str.match(httpUrlRegex)?.[0];
}
10 changes: 10 additions & 0 deletions extensions/positron-run-app/src/positron-run-app.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ export interface RunAppOptions {
* The optional app ready message to wait for in the terminal before previewing the application.
*/
appReadyMessage?: string;

/**
* An optional array of app URI formats to parse the URI from the terminal output.
*/
appUrlStrings?: string[];
}

/**
Expand Down Expand Up @@ -88,6 +93,11 @@ export interface DebugAppOptions {
* The optional app ready message to wait for in the terminal before previewing the application.
*/
appReadyMessage?: string;

/**
* An optional array of app URI formats to parse the URI from the terminal output.
*/
appUrlStrings?: string[];
}

/**
Expand Down

0 comments on commit 461485e

Please sign in to comment.