Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve debug config in flask #276

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions src/extension/common/utils/localize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,7 @@ export namespace DebugConfigStrings {
};
export const djangoConfigPromp = {
title: l10n.t('Debug Django'),
prompt: l10n.t(
"Enter the path to manage.py or select a file from the list ('${workspaceFolderToken}' points to the root of the current workspace folder)",
),
prompt: l10n.t('Enter the path to manage.py or select a file from the list.'),
};
}
export namespace fastapi {
Expand Down Expand Up @@ -132,6 +130,10 @@ export namespace DebugConfigStrings {
prompt: l10n.t('Python Debugger: Flask'),
invalid: l10n.t('Enter a valid name'),
};
export const flaskConfigPromp = {
title: l10n.t('Debug Flask'),
prompt: l10n.t('Enter the path to app.py or select a file from the list.'),
};
}
export namespace pyramid {
export const snippet = {
Expand Down
69 changes: 27 additions & 42 deletions src/extension/debugger/configuration/providers/flaskLaunch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,70 +5,55 @@
'use strict';

import * as path from 'path';
import * as fs from 'fs-extra';
import { WorkspaceFolder } from 'vscode';
import { Uri } from 'vscode';
import { DebugConfigStrings } from '../../../common/utils/localize';
import { MultiStepInput } from '../../../common/multiStepInput';
import { sendTelemetryEvent } from '../../../telemetry';
import { EventName } from '../../../telemetry/constants';
import { DebuggerTypeName } from '../../../constants';
import { LaunchRequestArguments } from '../../../types';
import { DebugConfigurationState, DebugConfigurationType } from '../../types';
import { DebugConfigurationState } from '../../types';
import { getFlaskPaths } from '../utils/configuration';
import { QuickPickType } from './providerQuickPick/types';
import { goToFileButton } from './providerQuickPick/providerQuickPick';
import { parseFlaskPath, pickFlaskPrompt } from './providerQuickPick/flaskProviderQuickPick';

export async function buildFlaskLaunchDebugConfiguration(
input: MultiStepInput<DebugConfigurationState>,
state: DebugConfigurationState,
): Promise<void> {
const application = await getApplicationPath(state.folder);
let manuallyEnteredAValue: boolean | undefined;
let flaskPaths = await getFlaskPaths(state.folder);
let options: QuickPickType[] = [];

const config: Partial<LaunchRequestArguments> = {
name: DebugConfigStrings.flask.snippet.name,
type: DebuggerTypeName,
request: 'launch',
module: 'flask',
env: {
FLASK_APP: application || 'app.py',
FLASK_APP: 'app.py',
FLASK_DEBUG: '1',
},
args: ['run', '--no-debugger', '--no-reload'],
jinja: true,
autoStartBrowser: false,
};

if (!application) {
const selectedApp = await input.showInputBox({
title: DebugConfigStrings.flask.enterAppPathOrNamePath.title,
value: 'app.py',
prompt: DebugConfigStrings.flask.enterAppPathOrNamePath.prompt,
validate: (value) =>
Promise.resolve(
value && value.trim().length > 0
? undefined
: DebugConfigStrings.flask.enterAppPathOrNamePath.invalid,
),
//add found paths to options
if (flaskPaths.length > 0) {
options.push(
...flaskPaths.map((item) => ({
label: path.basename(item.fsPath),
filePath: item,
description: parseFlaskPath(state.folder, item.fsPath),
buttons: [goToFileButton],
})),
);
} else {
const managePath = path.join(state?.folder?.uri.fsPath || '', 'app.py');
options.push({
label: 'Default',
description: parseFlaskPath(state.folder, managePath),
filePath: Uri.file(managePath),
});
if (selectedApp) {
manuallyEnteredAValue = true;
config.env!.FLASK_APP = selectedApp;
} else {
return;
}
}

sendTelemetryEvent(EventName.DEBUGGER_CONFIGURATION_PROMPTS, undefined, {
configurationType: DebugConfigurationType.launchFlask,
autoDetectedFlaskAppPyPath: !!application,
manuallyEnteredAValue,
});
Object.assign(state.config, config);
}
export async function getApplicationPath(folder: WorkspaceFolder | undefined): Promise<string | undefined> {
if (!folder) {
return undefined;
}
const defaultLocationOfManagePy = path.join(folder.uri.fsPath, 'app.py');
if (await fs.pathExists(defaultLocationOfManagePy)) {
return 'app.py';
}
return undefined;
await input.run((_input, state) => pickFlaskPrompt(input, state, config, options), state);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

import * as path from 'path';
import { window, QuickPickItemButtonEvent, QuickPickItemKind, WorkspaceFolder } from 'vscode';
import { IQuickPickParameters, InputFlowAction, MultiStepInput } from '../../../../common/multiStepInput';
import { LaunchRequestArguments } from '../../../../types';
import { DebugConfigurationState, DebugConfigurationType } from '../../../types';
import { QuickPickType } from './types';
import { browseFileOption, openFileExplorer } from './providerQuickPick';
import { DebugConfigStrings } from '../../../../common/utils/localize';
import { sendTelemetryEvent } from '../../../../telemetry';
import { EventName } from '../../../../telemetry/constants';

export async function pickFlaskPrompt(
input: MultiStepInput<DebugConfigurationState>,
state: DebugConfigurationState,
config: Partial<LaunchRequestArguments>,
pathsOptions: QuickPickType[],
) {
let options: QuickPickType[] = [
...pathsOptions,
{ label: '', kind: QuickPickItemKind.Separator },
browseFileOption,
];

const selection = await input.showQuickPick<QuickPickType, IQuickPickParameters<QuickPickType>>({
placeholder: DebugConfigStrings.flask.flaskConfigPromp.prompt,
items: options,
acceptFilterBoxTextAsSelection: true,
activeItem: options[0],
matchOnDescription: true,
title: DebugConfigStrings.flask.flaskConfigPromp.title,
onDidTriggerItemButton: async (e: QuickPickItemButtonEvent<QuickPickType>) => {
if (e.item && 'filePath' in e.item) {
await window.showTextDocument(e.item.filePath, { preview: true });
}
},
});

if (selection === undefined) {
return;
} else if (selection.label === browseFileOption.label) {
const uris = await openFileExplorer(state.folder?.uri);
if (uris && uris.length > 0) {
config.env!.FLASK_APP = parseFlaskPath(state.folder, uris[0].fsPath);
sendTelemetryEvent(EventName.DEBUGGER_CONFIGURATION_PROMPTS, undefined, {
configurationType: DebugConfigurationType.launchFlask,
browsefilevalue: true,
});
} else {
return Promise.reject(InputFlowAction.resume);
}
} else if (typeof selection === 'string') {
config.env!.FLASK_APP = selection;
sendTelemetryEvent(EventName.DEBUGGER_CONFIGURATION_PROMPTS, undefined, {
configurationType: DebugConfigurationType.launchFlask,
manuallyEnteredAValue: true,
});
} else {
config.env!.FLASK_APP = selection.description;
sendTelemetryEvent(EventName.DEBUGGER_CONFIGURATION_PROMPTS, undefined, {
configurationType: DebugConfigurationType.launchFlask,
autoDetectedFlaskAppPyPath: true,
});
}
Object.assign(state.config, config);
}

export function parseFlaskPath(folder: WorkspaceFolder | undefined, flaskPath: string): string | undefined {
if (!folder) {
return flaskPath;
}
const baseManagePath = path.relative(folder.uri.fsPath, flaskPath);
if (baseManagePath && !baseManagePath.startsWith('..')) {
return baseManagePath;
} else {
return flaskPath;
}
}
4 changes: 2 additions & 2 deletions src/extension/debugger/configuration/utils/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export async function getDjangoPaths(folder: WorkspaceFolder | undefined): Promi

export async function getFastApiPaths(folder: WorkspaceFolder | undefined) {
if (!folder) {
return undefined;
return [];
}
const regExpression = /app\s*=\s*FastAPI\(/;
const fastApiPaths = await getPossiblePaths(
Expand All @@ -83,7 +83,7 @@ export async function getFastApiPaths(folder: WorkspaceFolder | undefined) {

export async function getFlaskPaths(folder: WorkspaceFolder | undefined) {
if (!folder) {
return undefined;
return [];
}
const regExpression = /app(?:lication)?\s*=\s*(?:flask\.)?Flask\(|def\s+(?:create|make)_app\(/;
const flaskPaths = await getPossiblePaths(
Expand Down
108 changes: 46 additions & 62 deletions src/test/unittest/configuration/providers/flaskLaunch.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,87 +5,71 @@

import { expect } from 'chai';
import * as path from 'path';
import * as fs from 'fs-extra';
import * as sinon from 'sinon';
import { anything, instance, mock, when } from 'ts-mockito';
import { Uri } from 'vscode';
import { DebugConfigStrings } from '../../../../extension/common/utils/localize';
import { DebuggerTypeName } from '../../../../extension/constants';
import * as typemoq from 'typemoq';
import { ThemeIcon, Uri } from 'vscode';
import { DebugConfigurationState } from '../../../../extension/debugger/types';
import * as flaskLaunch from '../../../../extension/debugger/configuration/providers/flaskLaunch';
import { MultiStepInput } from '../../../../extension/common/multiStepInput';
import * as configuration from '../../../../extension/debugger/configuration/utils/configuration';
import * as flaskProviderQuickPick from '../../../../extension/debugger/configuration/providers/providerQuickPick/flaskProviderQuickPick';

suite('Debugging - Configuration Provider Flask', () => {
let pathExistsStub: sinon.SinonStub;
let input: MultiStepInput<DebugConfigurationState>;
let multiStepInput: typemoq.IMock<MultiStepInput<DebugConfigurationState>>;
let getFlaskPathsStub: sinon.SinonStub;
let pickFlaskPromptStub: sinon.SinonStub;

setup(() => {
input = mock<MultiStepInput<DebugConfigurationState>>(MultiStepInput);
pathExistsStub = sinon.stub(fs, 'pathExists');
multiStepInput = typemoq.Mock.ofType<MultiStepInput<DebugConfigurationState>>();
multiStepInput
.setup((i) => i.run(typemoq.It.isAny(), typemoq.It.isAny()))
.returns((callback, _state) => callback());
getFlaskPathsStub = sinon.stub(configuration, 'getFlaskPaths');
pickFlaskPromptStub = sinon.stub(flaskProviderQuickPick, 'pickFlaskPrompt');
});
teardown(() => {
sinon.restore();
});
test("getApplicationPath should return undefined if file doesn't exist", async () => {
const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 };
const appPyPath = path.join(folder.uri.fsPath, 'app.py');
pathExistsStub.withArgs(appPyPath).resolves(false);
const file = await flaskLaunch.getApplicationPath(folder);

expect(file).to.be.equal(undefined, 'Should return undefined');
});
test('getApplicationPath should file path', async () => {
const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 };
const appPyPath = path.join(folder.uri.fsPath, 'app.py');
pathExistsStub.withArgs(appPyPath).resolves(true);
const file = await flaskLaunch.getApplicationPath(folder);

expect(file).to.be.equal('app.py');
});
test('Launch JSON with selected app path', async () => {
test('Show picker and send parsed found flask paths', async () => {
const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 };
const state = { config: {}, folder };

when(input.showInputBox(anything())).thenResolve('hello');

await flaskLaunch.buildFlaskLaunchDebugConfiguration(instance(input), state);

const config = {
name: DebugConfigStrings.flask.snippet.name,
type: DebuggerTypeName,
request: 'launch',
module: 'flask',
env: {
FLASK_APP: 'hello',
FLASK_DEBUG: '1',
const appPath = Uri.file(path.join(folder.uri.fsPath, 'app.py'));
getFlaskPathsStub.resolves([appPath]);
pickFlaskPromptStub.resolves();
await flaskLaunch.buildFlaskLaunchDebugConfiguration(multiStepInput.object, state);
const options = pickFlaskPromptStub.getCall(0).args[3];
const expectedOptions = [
{
label: path.basename(appPath.fsPath),
filePath: appPath,
description: 'app.py',
buttons: [
{
iconPath: new ThemeIcon('go-to-file'),
tooltip: `Open in Preview`,
},
],
},
args: ['run', '--no-debugger', '--no-reload'],
jinja: true,
autoStartBrowser: false,
};
];

expect(state.config).to.be.deep.equal(config);
expect(options).to.be.deep.equal(expectedOptions);
});
test('Launch JSON with default managepy path', async () => {
test('Show picker and send default app.py path', async () => {
const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 };
const state = { config: {}, folder };
when(input.showInputBox(anything())).thenResolve('app.py');

await flaskLaunch.buildFlaskLaunchDebugConfiguration(instance(input), state);

const config = {
name: DebugConfigStrings.flask.snippet.name,
type: DebuggerTypeName,
request: 'launch',
module: 'flask',
env: {
FLASK_APP: 'app.py',
FLASK_DEBUG: '1',
const appPath = path.join(state?.folder?.uri.fsPath, 'app.py');
getFlaskPathsStub.resolves([]);
pickFlaskPromptStub.resolves();
await flaskLaunch.buildFlaskLaunchDebugConfiguration(multiStepInput.object, state);
const options = pickFlaskPromptStub.getCall(0).args[3];
const expectedOptions = [
{
label: 'Default',
filePath: Uri.file(appPath),
description: 'app.py',
},
args: ['run', '--no-debugger', '--no-reload'],
jinja: true,
autoStartBrowser: false,
};
];

expect(state.config).to.be.deep.equal(config);
expect(options).to.be.deep.equal(expectedOptions);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

'use strict';

import { Uri } from 'vscode';
import { expect } from 'chai';
import * as path from 'path';
import * as typemoq from 'typemoq';
import * as sinon from 'sinon';
import { MultiStepInput } from '../../../../../extension/common/multiStepInput';
import { DebugConfigurationState } from '../../../../../extension/debugger/types';
import { parseFlaskPath } from '../../../../../extension/debugger/configuration/providers/providerQuickPick/flaskProviderQuickPick';

suite('Debugging - Configuration Provider Flask QuickPick', () => {
let pathSeparatorStub: sinon.SinonStub;
let multiStepInput: typemoq.IMock<MultiStepInput<DebugConfigurationState>>;

setup(() => {
multiStepInput = typemoq.Mock.ofType<MultiStepInput<DebugConfigurationState>>();
multiStepInput
.setup((i) => i.run(typemoq.It.isAny(), typemoq.It.isAny()))
.returns((callback, _state) => callback());
pathSeparatorStub = sinon.stub(path, 'sep');
pathSeparatorStub.value('-');
});
teardown(() => {
sinon.restore();
});
test('parseManagePyPath should parse the path and return it with workspaceFolderToken', () => {
const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 };
const flaskPath = path.join(folder.uri.fsPath, 'app.py');
const file = parseFlaskPath(folder, flaskPath);
pathSeparatorStub.value('-');
const expectedValue = `app.py`;
expect(file).to.be.equal(expectedValue);
});
test('parseManagePyPath should return the same path if the workspace do not match', () => {
const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 };
const flaskPath = 'random/path/app.py';
const file = parseFlaskPath(folder, flaskPath);

expect(file).to.be.equal(flaskPath);
});
});
Loading