diff --git a/src/extension/common/utils/localize.ts b/src/extension/common/utils/localize.ts index 8636c962..5c61c3aa 100644 --- a/src/extension/common/utils/localize.ts +++ b/src/extension/common/utils/localize.ts @@ -111,10 +111,17 @@ export namespace DebugConfigStrings { label: l10n.t('FastAPI'), description: l10n.t('Launch and debug a FastAPI web application'), }; - export const enterAppPathOrNamePath = { + export const snippetFile = { + name: l10n.t('Python Debugger: FastAPI File'), + }; + export const selectConfigurationWithFile = { + label: l10n.t('FastAPI File'), + description: l10n.t('Launch and debug a FastAPI web application using the current file'), + }; + export const enterAppPath = { title: l10n.t('Debug FastAPI'), - prompt: l10n.t("Enter the path to the application, e.g. 'main.py' or 'main'"), - invalid: l10n.t('Enter a valid name'), + prompt: l10n.t('Enter the path to your FastAPI app (e.g. main.py or backend/app/main.py).'), + invalid: l10n.t('Enter a valid path'), }; } export namespace flask { diff --git a/src/extension/debugger/configuration/debugConfigurationService.ts b/src/extension/debugger/configuration/debugConfigurationService.ts index 9e4e196e..38dfa8db 100644 --- a/src/extension/debugger/configuration/debugConfigurationService.ts +++ b/src/extension/debugger/configuration/debugConfigurationService.ts @@ -8,7 +8,10 @@ import { IMultiStepInputFactory, InputStep, IQuickPickParameters, MultiStepInput import { AttachRequestArguments, DebugConfigurationArguments, LaunchRequestArguments } from '../../types'; import { DebugConfigurationState, DebugConfigurationType, IDebugConfigurationService } from '../types'; import { buildDjangoLaunchDebugConfiguration } from './providers/djangoLaunch'; -import { buildFastAPILaunchDebugConfiguration } from './providers/fastapiLaunch'; +import { + buildFastAPILaunchDebugConfiguration, + buildFastAPIWithFileLaunchDebugConfiguration, +} from './providers/fastapiLaunch'; import { buildFileLaunchDebugConfiguration } from './providers/fileLaunch'; import { buildFlaskLaunchDebugConfiguration } from './providers/flaskLaunch'; import { buildModuleLaunchConfiguration } from './providers/moduleLaunch'; @@ -158,6 +161,11 @@ export class PythonDebugConfigurationService implements IDebugConfigurationServi type: DebugConfigurationType.launchFastAPI, description: DebugConfigStrings.fastapi.selectConfiguration.description, }, + { + label: DebugConfigStrings.fastapi.selectConfigurationWithFile.label, + type: DebugConfigurationType.launchFastAPIWithFile, + description: DebugConfigStrings.fastapi.selectConfigurationWithFile.description, + }, { label: DebugConfigStrings.flask.selectConfiguration.label, type: DebugConfigurationType.launchFlask, @@ -178,6 +186,10 @@ export class PythonDebugConfigurationService implements IDebugConfigurationServi >(); debugConfigurations.set(DebugConfigurationType.launchDjango, buildDjangoLaunchDebugConfiguration); debugConfigurations.set(DebugConfigurationType.launchFastAPI, buildFastAPILaunchDebugConfiguration); + debugConfigurations.set( + DebugConfigurationType.launchFastAPIWithFile, + buildFastAPIWithFileLaunchDebugConfiguration, + ); debugConfigurations.set(DebugConfigurationType.launchFile, buildFileLaunchDebugConfiguration); debugConfigurations.set(DebugConfigurationType.launchFileWithArgs, buildFileWithArgsLaunchDebugConfiguration); debugConfigurations.set(DebugConfigurationType.launchFlask, buildFlaskLaunchDebugConfiguration); diff --git a/src/extension/debugger/configuration/dynamicdebugConfigurationService.ts b/src/extension/debugger/configuration/dynamicdebugConfigurationService.ts index 00f590d3..f3903106 100644 --- a/src/extension/debugger/configuration/dynamicdebugConfigurationService.ts +++ b/src/extension/debugger/configuration/dynamicdebugConfigurationService.ts @@ -8,8 +8,7 @@ import * as path from 'path'; import { CancellationToken, DebugConfiguration, WorkspaceFolder } from 'vscode'; import { IDynamicDebugConfigurationService } from '../types'; import { DebuggerTypeName } from '../../constants'; -import { replaceAll } from '../../common/stringUtils'; -import { getDjangoPaths, getFastApiPaths, getFlaskPaths } from './utils/configuration'; +import { getDjangoPaths, getFastApiPaths, getFlaskPaths, tryResolveFastApiArgs } from './utils/configuration'; import { sendTelemetryEvent } from '../../telemetry'; import { EventName } from '../../telemetry/constants'; @@ -63,15 +62,22 @@ export class DynamicPythonDebugConfigurationService implements IDynamicDebugConf } const fastApiPaths = await getFastApiPaths(folder); - let fastApiPath = fastApiPaths?.length ? fastApiPaths[0].fsPath : null; - if (fastApiPath) { - fastApiPath = replaceAll(path.relative(folder.uri.fsPath, fastApiPath), path.sep, '.').replace('.py', ''); + if (fastApiPaths?.length) { + const fastApiArgs = tryResolveFastApiArgs(folder, fastApiPaths) ?? ['run']; providers.push({ name: 'Python Debugger: FastAPI', type: DebuggerTypeName, request: 'launch', - module: 'uvicorn', - args: [`${fastApiPath}:app`, '--reload'], + module: 'fastapi', + args: fastApiArgs, + jinja: true, + }); + providers.push({ + name: 'Python Debugger: FastAPI File', + type: DebuggerTypeName, + request: 'launch', + module: 'fastapi', + args: ['run', '${file}'], jinja: true, }); } diff --git a/src/extension/debugger/configuration/providers/fastapiLaunch.ts b/src/extension/debugger/configuration/providers/fastapiLaunch.ts index 6755c71c..9e49ccb0 100644 --- a/src/extension/debugger/configuration/providers/fastapiLaunch.ts +++ b/src/extension/debugger/configuration/providers/fastapiLaunch.ts @@ -4,8 +4,6 @@ 'use strict'; import * as path from 'path'; -import * as fs from 'fs-extra'; -import { WorkspaceFolder } from 'vscode'; import { MultiStepInput } from '../../../common/multiStepInput'; import { DebugConfigStrings } from '../../../common/utils/localize'; import { sendTelemetryEvent } from '../../../telemetry'; @@ -13,56 +11,75 @@ import { EventName } from '../../../telemetry/constants'; import { DebuggerTypeName } from '../../../constants'; import { LaunchRequestArguments } from '../../../types'; import { DebugConfigurationState, DebugConfigurationType } from '../../types'; +import { getFastApiPaths, tryResolveFastApiArgs } from '../utils/configuration'; + +async function promptForAppPath( + input: MultiStepInput, + value?: string, +): Promise { + const entered = await input.showInputBox({ + title: DebugConfigStrings.fastapi.enterAppPath.title, + prompt: DebugConfigStrings.fastapi.enterAppPath.prompt, + value: value ?? '', + validate: (v) => + Promise.resolve(v && v.trim().length > 0 ? undefined : DebugConfigStrings.fastapi.enterAppPath.invalid), + }); + return entered?.trim(); +} export async function buildFastAPILaunchDebugConfiguration( input: MultiStepInput, state: DebugConfigurationState, ): Promise { - const application = await getApplicationPath(state.folder); - let manuallyEnteredAValue: boolean | undefined; + const fastApiPaths = await getFastApiPaths(state.folder); + const autoArgs = state.folder ? tryResolveFastApiArgs(state.folder, fastApiPaths) : undefined; + + let args: string[]; + let manuallyEnteredAValue = false; + if (autoArgs) { + args = autoArgs; + } else { + const workspaceRoot = state.folder?.uri.fsPath; + const prefill = + workspaceRoot && fastApiPaths.length > 0 ? path.relative(workspaceRoot, fastApiPaths[0].fsPath) : undefined; + const entered = await promptForAppPath(input, prefill); + if (!entered) { + return; + } + args = ['run', entered]; + manuallyEnteredAValue = true; + } + const config: Partial = { name: DebugConfigStrings.fastapi.snippet.name, type: DebuggerTypeName, request: 'launch', - module: 'uvicorn', - args: ['main:app', '--reload'], + module: 'fastapi', + args, jinja: true, }; - - if (!application) { - const selectedPath = await input.showInputBox({ - title: DebugConfigStrings.fastapi.enterAppPathOrNamePath.title, - value: 'main.py', - prompt: DebugConfigStrings.fastapi.enterAppPathOrNamePath.prompt, - validate: (value) => - Promise.resolve( - value && value.trim().length > 0 - ? undefined - : DebugConfigStrings.fastapi.enterAppPathOrNamePath.invalid, - ), - }); - if (selectedPath) { - manuallyEnteredAValue = true; - config.args = [`${path.basename(selectedPath, '.py').replace('/', '.')}:app`, '--reload']; - } else { - return; - } - } - sendTelemetryEvent(EventName.DEBUGGER_CONFIGURATION_PROMPTS, undefined, { configurationType: DebugConfigurationType.launchFastAPI, - autoDetectedFastAPIMainPyPath: !!application, + autoDetectedFastAPIMainPyPath: !!autoArgs, manuallyEnteredAValue, }); Object.assign(state.config, config); } -export async function getApplicationPath(folder: WorkspaceFolder | undefined): Promise { - if (!folder) { - return undefined; - } - const defaultLocationOfManagePy = path.join(folder.uri.fsPath, 'main.py'); - if (await fs.pathExists(defaultLocationOfManagePy)) { - return 'main.py'; - } - return undefined; + +export async function buildFastAPIWithFileLaunchDebugConfiguration( + _input: MultiStepInput, + state: DebugConfigurationState, +): Promise { + const config: Partial = { + name: DebugConfigStrings.fastapi.snippetFile.name, + type: DebuggerTypeName, + request: 'launch', + module: 'fastapi', + args: ['run', '${file}'], + jinja: true, + }; + sendTelemetryEvent(EventName.DEBUGGER_CONFIGURATION_PROMPTS, undefined, { + configurationType: DebugConfigurationType.launchFastAPIWithFile, + }); + Object.assign(state.config, config); } diff --git a/src/extension/debugger/configuration/utils/configuration.ts b/src/extension/debugger/configuration/utils/configuration.ts index a3af3681..33281396 100644 --- a/src/extension/debugger/configuration/utils/configuration.ts +++ b/src/extension/debugger/configuration/utils/configuration.ts @@ -7,6 +7,7 @@ 'use strict'; import * as fs from 'fs-extra'; +import * as path from 'path'; import { MultiStepInput } from '../../../common/multiStepInput'; import { sendTelemetryEvent } from '../../../telemetry'; import { EventName } from '../../../telemetry/constants'; @@ -81,6 +82,14 @@ export async function getFastApiPaths(folder: WorkspaceFolder | undefined) { return fastApiPaths; } +export function tryResolveFastApiArgs(folder: WorkspaceFolder, paths: readonly Uri[]): string[] | undefined { + if (paths.length !== 1) { + return undefined; + } + const relative = path.relative(folder.uri.fsPath, paths[0].fsPath); + return ['run', relative]; +} + export async function getFlaskPaths(folder: WorkspaceFolder | undefined) { if (!folder) { return []; diff --git a/src/extension/debugger/types.ts b/src/extension/debugger/types.ts index d342f758..1e1c956b 100644 --- a/src/extension/debugger/types.ts +++ b/src/extension/debugger/types.ts @@ -34,6 +34,7 @@ export enum DebugConfigurationType { remoteAttach = 'remoteAttach', launchDjango = 'launchDjango', launchFastAPI = 'launchFastAPI', + launchFastAPIWithFile = 'launchFastAPIWithFile', launchFlask = 'launchFlask', launchModule = 'launchModule', launchPyramid = 'launchPyramid', diff --git a/src/test/unittest/configuration/dynamicdebugConfigurationService.unit.test.ts b/src/test/unittest/configuration/dynamicdebugConfigurationService.unit.test.ts new file mode 100644 index 00000000..710f9823 --- /dev/null +++ b/src/test/unittest/configuration/dynamicdebugConfigurationService.unit.test.ts @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +import { expect } from 'chai'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import { Uri, WorkspaceFolder } from 'vscode'; +import { DebuggerTypeName } from '../../../extension/constants'; +import { DynamicPythonDebugConfigurationService } from '../../../extension/debugger/configuration/dynamicdebugConfigurationService'; +import * as configurationUtils from '../../../extension/debugger/configuration/utils/configuration'; + +suite('Debugging - Dynamic Debug Configuration Service', () => { + const folder: WorkspaceFolder = { uri: Uri.file('/work'), name: 'ws', index: 0 }; + let service: DynamicPythonDebugConfigurationService; + let getFastApiPathsStub: sinon.SinonStub; + + setup(() => { + service = new DynamicPythonDebugConfigurationService(); + sinon.stub(configurationUtils, 'getDjangoPaths').resolves([]); + sinon.stub(configurationUtils, 'getFlaskPaths').resolves([]); + getFastApiPathsStub = sinon.stub(configurationUtils, 'getFastApiPaths'); + }); + + teardown(() => { + sinon.restore(); + }); + + const fastApiProviders = async () => { + const result = await service.provideDebugConfigurations(folder); + return (result ?? []).filter((c) => c.name?.includes('FastAPI')); + }; + + const fileVariantConfig = { + name: 'Python Debugger: FastAPI File', + type: DebuggerTypeName, + request: 'launch', + module: 'fastapi', + args: ['run', '${file}'], + jinja: true, + }; + + test('No FastAPI detected → no FastAPI configs offered', async () => { + getFastApiPathsStub.resolves([]); + + const fastApi = await fastApiProviders(); + expect(fastApi).to.have.length(0); + }); + + test('Single match at workspace root → project config uses resolved path, file variant uses ${file}', async () => { + getFastApiPathsStub.resolves([Uri.file('/work/main.py')]); + + const fastApi = await fastApiProviders(); + expect(fastApi).to.have.length(2); + expect(fastApi[0]).to.deep.equal({ + name: 'Python Debugger: FastAPI', + type: DebuggerTypeName, + request: 'launch', + module: 'fastapi', + args: ['run', 'main.py'], + jinja: true, + }); + expect(fastApi[1]).to.deep.equal(fileVariantConfig); + }); + + test('Single match in subdirectory → project config passes path explicitly', async () => { + getFastApiPathsStub.resolves([Uri.file('/work/backend/app/main.py')]); + + const fastApi = await fastApiProviders(); + expect(fastApi).to.have.length(2); + expect(fastApi[0]).to.deep.equal({ + name: 'Python Debugger: FastAPI', + type: DebuggerTypeName, + request: 'launch', + module: 'fastapi', + args: ['run', path.join('backend', 'app', 'main.py')], + jinja: true, + }); + expect(fastApi[1]).to.deep.equal(fileVariantConfig); + }); + + test('Multiple matches → project config falls back to plain `fastapi run`', async () => { + getFastApiPathsStub.resolves([Uri.file('/work/svc-a/main.py'), Uri.file('/work/svc-b/main.py')]); + + const fastApi = await fastApiProviders(); + expect(fastApi).to.have.length(2); + expect(fastApi[0]).to.deep.equal({ + name: 'Python Debugger: FastAPI', + type: DebuggerTypeName, + request: 'launch', + module: 'fastapi', + args: ['run'], + jinja: true, + }); + expect(fastApi[1]).to.deep.equal(fileVariantConfig); + }); +}); diff --git a/src/test/unittest/configuration/providers/fastapiLaunch.unit.test.ts b/src/test/unittest/configuration/providers/fastapiLaunch.unit.test.ts index f948c147..7511b102 100644 --- a/src/test/unittest/configuration/providers/fastapiLaunch.unit.test.ts +++ b/src/test/unittest/configuration/providers/fastapiLaunch.unit.test.ts @@ -5,48 +5,95 @@ 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 fastApiLaunch from '../../../../extension/debugger/configuration/providers/fastapiLaunch'; +import * as configurationUtils from '../../../../extension/debugger/configuration/utils/configuration'; import { DebugConfigurationState } from '../../../../extension/debugger/types'; import { MultiStepInput } from '../../../../extension/common/multiStepInput'; suite('Debugging - Configuration Provider FastAPI', () => { let input: MultiStepInput; - let pathExistsStub: sinon.SinonStub; + let getFastApiPathsStub: sinon.SinonStub; setup(() => { input = mock>(MultiStepInput); - pathExistsStub = sinon.stub(fs, 'pathExists'); + getFastApiPathsStub = sinon.stub(configurationUtils, 'getFastApiPaths'); }); + teardown(() => { sinon.restore(); }); - test("getApplicationPath should return undefined if file doesn't exist", async () => { + + test('Single match at workspace root → passes path explicitly', async () => { const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 }; - const appPyPath = path.join(folder.uri.fsPath, 'main.py'); - pathExistsStub.withArgs(appPyPath).resolves(false); - const file = await fastApiLaunch.getApplicationPath(folder); + const state = { config: {}, folder }; + getFastApiPathsStub.resolves([Uri.parse(path.join('one', 'two', 'main.py'))]); + + await fastApiLaunch.buildFastAPILaunchDebugConfiguration(instance(input), state); + + const config = { + name: DebugConfigStrings.fastapi.snippet.name, + type: DebuggerTypeName, + request: 'launch', + module: 'fastapi', + args: ['run', 'main.py'], + jinja: true, + }; - expect(file).to.be.equal(undefined, 'Should return undefined'); + expect(state.config).to.be.deep.equal(config); }); - test('getApplicationPath should find path', async () => { + + test('Single match in subdirectory → passes path explicitly', async () => { const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 }; - const appPyPath = path.join(folder.uri.fsPath, 'main.py'); - pathExistsStub.withArgs(appPyPath).resolves(true); - const file = await fastApiLaunch.getApplicationPath(folder); + const state = { config: {}, folder }; + getFastApiPathsStub.resolves([Uri.parse(path.join('one', 'two', 'backend', 'app', 'main.py'))]); + + await fastApiLaunch.buildFastAPILaunchDebugConfiguration(instance(input), state); + + const config = { + name: DebugConfigStrings.fastapi.snippet.name, + type: DebuggerTypeName, + request: 'launch', + module: 'fastapi', + args: ['run', path.join('backend', 'app', 'main.py')], + jinja: true, + }; - expect(file).to.be.equal('main.py'); + expect(state.config).to.be.deep.equal(config); }); - test('Launch JSON with selected app path', async () => { + + test('No matches → prompts the user and uses the entered path', async () => { const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 }; const state = { config: {}, folder }; + getFastApiPathsStub.resolves([]); + when(input.showInputBox(anything())).thenResolve('custom/main.py'); + + await fastApiLaunch.buildFastAPILaunchDebugConfiguration(instance(input), state); - when(input.showInputBox(anything())).thenResolve('main'); + const config = { + name: DebugConfigStrings.fastapi.snippet.name, + type: DebuggerTypeName, + request: 'launch', + module: 'fastapi', + args: ['run', 'custom/main.py'], + jinja: true, + }; + + expect(state.config).to.be.deep.equal(config); + }); + + test('Multiple matches → prompts the user and uses the entered path', async () => { + const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 }; + const state = { config: {}, folder }; + getFastApiPathsStub.resolves([ + Uri.parse(path.join('one', 'two', 'svc-a', 'main.py')), + Uri.parse(path.join('one', 'two', 'svc-b', 'main.py')), + ]); + when(input.showInputBox(anything())).thenResolve(path.join('svc-a', 'main.py')); await fastApiLaunch.buildFastAPILaunchDebugConfiguration(instance(input), state); @@ -54,11 +101,73 @@ suite('Debugging - Configuration Provider FastAPI', () => { name: DebugConfigStrings.fastapi.snippet.name, type: DebuggerTypeName, request: 'launch', - module: 'uvicorn', - args: ['main:app', '--reload'], + module: 'fastapi', + args: ['run', path.join('svc-a', 'main.py')], jinja: true, }; expect(state.config).to.be.deep.equal(config); }); + + test('User cancels prompt → config is not populated', async () => { + const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 }; + const state = { config: {}, folder }; + getFastApiPathsStub.resolves([]); + when(input.showInputBox(anything())).thenResolve(undefined); + + await fastApiLaunch.buildFastAPILaunchDebugConfiguration(instance(input), state); + + expect(state.config).to.be.deep.equal({}); + }); + + test('Launch JSON with file configuration', async () => { + const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 }; + const state = { config: {}, folder }; + + await fastApiLaunch.buildFastAPIWithFileLaunchDebugConfiguration(instance(input), state); + + const config = { + name: DebugConfigStrings.fastapi.snippetFile.name, + type: DebuggerTypeName, + request: 'launch', + module: 'fastapi', + args: ['run', '${file}'], + jinja: true, + }; + + expect(state.config).to.be.deep.equal(config); + }); +}); + +suite('Debugging - tryResolveFastApiArgs', () => { + const workspaceFolder = (root: string) => ({ + uri: Uri.file(root), + name: 'ws', + index: 0, + }); + + test('Returns undefined for empty paths', () => { + expect(configurationUtils.tryResolveFastApiArgs(workspaceFolder('/work'), [])).to.equal(undefined); + }); + + test('Returns undefined for multiple paths', () => { + const paths = [Uri.file('/work/svc-a/main.py'), Uri.file('/work/svc-b/main.py')]; + expect(configurationUtils.tryResolveFastApiArgs(workspaceFolder('/work'), paths)).to.equal(undefined); + }); + + test('Returns resolved path for single root-level match', () => { + const paths = [Uri.file('/work/main.py')]; + expect(configurationUtils.tryResolveFastApiArgs(workspaceFolder('/work'), paths)).to.deep.equal([ + 'run', + 'main.py', + ]); + }); + + test('Returns resolved path for single nested match', () => { + const paths = [Uri.file('/work/backend/app/main.py')]; + expect(configurationUtils.tryResolveFastApiArgs(workspaceFolder('/work'), paths)).to.deep.equal([ + 'run', + path.join('backend', 'app', 'main.py'), + ]); + }); });