From dcd8b80030476bec781a6e11b0c1db389493c988 Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Wed, 27 May 2026 09:42:17 -0700 Subject: [PATCH 1/9] Split config --- src/extension/common/utils/localize.ts | 5 --- .../dynamicdebugConfigurationService.ts | 10 ++--- .../configuration/providers/fastapiLaunch.ts | 45 ++----------------- .../providers/fastapiLaunch.unit.test.ts | 35 +++------------ 4 files changed, 11 insertions(+), 84 deletions(-) diff --git a/src/extension/common/utils/localize.ts b/src/extension/common/utils/localize.ts index 8636c962..a08eb5cd 100644 --- a/src/extension/common/utils/localize.ts +++ b/src/extension/common/utils/localize.ts @@ -111,11 +111,6 @@ export namespace DebugConfigStrings { label: l10n.t('FastAPI'), description: l10n.t('Launch and debug a FastAPI web application'), }; - export const enterAppPathOrNamePath = { - 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'), - }; } export namespace flask { export const snippet = { diff --git a/src/extension/debugger/configuration/dynamicdebugConfigurationService.ts b/src/extension/debugger/configuration/dynamicdebugConfigurationService.ts index 00f590d3..a0118baa 100644 --- a/src/extension/debugger/configuration/dynamicdebugConfigurationService.ts +++ b/src/extension/debugger/configuration/dynamicdebugConfigurationService.ts @@ -8,7 +8,6 @@ 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 { sendTelemetryEvent } from '../../telemetry'; import { EventName } from '../../telemetry/constants'; @@ -63,16 +62,13 @@ 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) { providers.push({ name: 'Python Debugger: FastAPI', type: DebuggerTypeName, request: 'launch', - module: 'uvicorn', - args: [`${fastApiPath}:app`, '--reload'], - jinja: true, + module: 'fastapi', + args: ['run'], }); } diff --git a/src/extension/debugger/configuration/providers/fastapiLaunch.ts b/src/extension/debugger/configuration/providers/fastapiLaunch.ts index 6755c71c..66f7474c 100644 --- a/src/extension/debugger/configuration/providers/fastapiLaunch.ts +++ b/src/extension/debugger/configuration/providers/fastapiLaunch.ts @@ -3,9 +3,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'; @@ -15,54 +12,18 @@ import { LaunchRequestArguments } from '../../../types'; import { DebugConfigurationState, DebugConfigurationType } from '../../types'; export async function buildFastAPILaunchDebugConfiguration( - input: MultiStepInput, + _input: MultiStepInput, state: DebugConfigurationState, ): Promise { - const application = await getApplicationPath(state.folder); - let manuallyEnteredAValue: boolean | undefined; const config: Partial = { name: DebugConfigStrings.fastapi.snippet.name, type: DebuggerTypeName, request: 'launch', - module: 'uvicorn', - args: ['main:app', '--reload'], - jinja: true, + module: 'fastapi', + args: ['run'], }; - - 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, - 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; -} diff --git a/src/test/unittest/configuration/providers/fastapiLaunch.unit.test.ts b/src/test/unittest/configuration/providers/fastapiLaunch.unit.test.ts index f948c147..aa8711e4 100644 --- a/src/test/unittest/configuration/providers/fastapiLaunch.unit.test.ts +++ b/src/test/unittest/configuration/providers/fastapiLaunch.unit.test.ts @@ -4,11 +4,9 @@ 'use strict'; 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 { instance, mock } from 'ts-mockito'; import { Uri } from 'vscode'; +import * as path from 'path'; import { DebugConfigStrings } from '../../../../extension/common/utils/localize'; import { DebuggerTypeName } from '../../../../extension/constants'; import * as fastApiLaunch from '../../../../extension/debugger/configuration/providers/fastapiLaunch'; @@ -17,46 +15,23 @@ import { MultiStepInput } from '../../../../extension/common/multiStepInput'; suite('Debugging - Configuration Provider FastAPI', () => { let input: MultiStepInput; - let pathExistsStub: sinon.SinonStub; setup(() => { input = mock>(MultiStepInput); - pathExistsStub = sinon.stub(fs, 'pathExists'); - }); - 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, 'main.py'); - pathExistsStub.withArgs(appPyPath).resolves(false); - const file = await fastApiLaunch.getApplicationPath(folder); - expect(file).to.be.equal(undefined, 'Should return undefined'); - }); - test('getApplicationPath should find path', 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); - - expect(file).to.be.equal('main.py'); - }); - test('Launch JSON with selected app path', async () => { + test('Launch JSON with default configuration', async () => { const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 }; const state = { config: {}, folder }; - when(input.showInputBox(anything())).thenResolve('main'); - await fastApiLaunch.buildFastAPILaunchDebugConfiguration(instance(input), state); const config = { name: DebugConfigStrings.fastapi.snippet.name, type: DebuggerTypeName, request: 'launch', - module: 'uvicorn', - args: ['main:app', '--reload'], - jinja: true, + module: 'fastapi', + args: ['run'], }; expect(state.config).to.be.deep.equal(config); From 06fc5bb33d3885e7714d162fe09661a0c8d78fc3 Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Wed, 27 May 2026 11:21:42 -0700 Subject: [PATCH 2/9] Refactor for dev and cli detection --- src/extension/common/utils/localize.ts | 7 ++++ .../debugConfigurationService.ts | 8 +++- .../dynamicdebugConfigurationService.ts | 42 +++++++++++++++---- .../configuration/providers/fastapiLaunch.ts | 23 +++++++++- .../configuration/utils/configuration.ts | 19 +++++++++ src/extension/debugger/types.ts | 1 + .../providers/fastapiLaunch.unit.test.ts | 23 +++++++++- 7 files changed, 112 insertions(+), 11 deletions(-) diff --git a/src/extension/common/utils/localize.ts b/src/extension/common/utils/localize.ts index a08eb5cd..2e94559e 100644 --- a/src/extension/common/utils/localize.ts +++ b/src/extension/common/utils/localize.ts @@ -111,6 +111,13 @@ export namespace DebugConfigStrings { label: l10n.t('FastAPI'), description: l10n.t('Launch and debug a FastAPI web application'), }; + 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 namespace flask { export const snippet = { diff --git a/src/extension/debugger/configuration/debugConfigurationService.ts b/src/extension/debugger/configuration/debugConfigurationService.ts index 9e4e196e..79d55188 100644 --- a/src/extension/debugger/configuration/debugConfigurationService.ts +++ b/src/extension/debugger/configuration/debugConfigurationService.ts @@ -8,7 +8,7 @@ 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 +158,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 +183,7 @@ 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 a0118baa..f3ff7412 100644 --- a/src/extension/debugger/configuration/dynamicdebugConfigurationService.ts +++ b/src/extension/debugger/configuration/dynamicdebugConfigurationService.ts @@ -8,9 +8,10 @@ import * as path from 'path'; import { CancellationToken, DebugConfiguration, WorkspaceFolder } from 'vscode'; import { IDynamicDebugConfigurationService } from '../types'; import { DebuggerTypeName } from '../../constants'; -import { getDjangoPaths, getFastApiPaths, getFlaskPaths } from './utils/configuration'; +import { getDjangoPaths, getFastApiPaths, getFlaskPaths, isFastApiCliAvailable } from './utils/configuration'; import { sendTelemetryEvent } from '../../telemetry'; import { EventName } from '../../telemetry/constants'; +import { replaceAll } from '../../common/stringUtils'; const workspaceFolderToken = '${workspaceFolder}'; @@ -63,13 +64,38 @@ export class DynamicPythonDebugConfigurationService implements IDynamicDebugConf const fastApiPaths = await getFastApiPaths(folder); if (fastApiPaths?.length) { - providers.push({ - name: 'Python Debugger: FastAPI', - type: DebuggerTypeName, - request: 'launch', - module: 'fastapi', - args: ['run'], - }); + const hasFastApiCli = await isFastApiCliAvailable(folder.uri); + if (hasFastApiCli) { + providers.push({ + name: 'Python Debugger: FastAPI', + type: DebuggerTypeName, + request: 'launch', + module: 'fastapi', + args: ['dev'], + jinja: true, + subProcess: true, + }); + providers.push({ + name: 'Python Debugger: FastAPI File', + type: DebuggerTypeName, + request: 'launch', + module: 'fastapi', + args: ['dev', '${file}'], + jinja: true, + subProcess: true, + }); + } else { + // Legacy fallback when fastapi-cli is not available. + const fastApiPath = replaceAll(path.relative(folder.uri.fsPath, fastApiPaths[0].fsPath), path.sep, '.').replace('.py', ''); + providers.push({ + name: 'Python Debugger: FastAPI', + type: DebuggerTypeName, + request: 'launch', + module: 'uvicorn', + args: [`${fastApiPath}:app`, '--reload'], + jinja: true, + }); + } } sendTelemetryEvent(EventName.DEBUGGER_DYNAMIC_CONFIGURATION, undefined, { providers: providers }); diff --git a/src/extension/debugger/configuration/providers/fastapiLaunch.ts b/src/extension/debugger/configuration/providers/fastapiLaunch.ts index 66f7474c..de4b2539 100644 --- a/src/extension/debugger/configuration/providers/fastapiLaunch.ts +++ b/src/extension/debugger/configuration/providers/fastapiLaunch.ts @@ -20,10 +20,31 @@ export async function buildFastAPILaunchDebugConfiguration( type: DebuggerTypeName, request: 'launch', module: 'fastapi', - args: ['run'], + args: ['dev'], + jinja: true, + subProcess: true, }; sendTelemetryEvent(EventName.DEBUGGER_CONFIGURATION_PROMPTS, undefined, { configurationType: DebugConfigurationType.launchFastAPI, }); Object.assign(state.config, config); } + +export async function buildFastAPIWithFileLaunchDebugConfiguration( + _input: MultiStepInput, + state: DebugConfigurationState, +): Promise { + const config: Partial = { + name: DebugConfigStrings.fastapi.snippetFile.name, + type: DebuggerTypeName, + request: 'launch', + module: 'fastapi', + args: ['dev', '${file}'], + jinja: true, + subProcess: true, + }; + sendTelemetryEvent(EventName.DEBUGGER_CONFIGURATION_PROMPTS, undefined, { + configurationType: DebugConfigurationType.launchFastAPIWithFile, + }); + Object.assign(state.config, config); +} \ No newline at end of file diff --git a/src/extension/debugger/configuration/utils/configuration.ts b/src/extension/debugger/configuration/utils/configuration.ts index a3af3681..ed13b67b 100644 --- a/src/extension/debugger/configuration/utils/configuration.ts +++ b/src/extension/debugger/configuration/utils/configuration.ts @@ -15,8 +15,11 @@ import { AttachRequestArguments } from '../../../types'; import { DebugConfigurationState, DebugConfigurationType } from '../../types'; import { Uri, WorkspaceFolder, workspace } from 'vscode'; import { asyncFilter } from '../../../common/utilities'; +import { getInterpreterDetails } from '../../../common/python'; +import { plainExec } from '../../../common/process/rawProcessApis'; const defaultPort = 5678; +const fastApiCliCache = new Map>(); export async function configurePort( input: MultiStepInput, @@ -81,6 +84,22 @@ export async function getFastApiPaths(folder: WorkspaceFolder | undefined) { return fastApiPaths; } +export async function isFastApiCliAvailable(resource?: Uri): Promise { + const interpreterDetails = await getInterpreterDetails(resource); + const pythonPath = interpreterDetails?.path?.[0]; + if (!pythonPath) { + return false; + } + if (fastApiCliCache.has(pythonPath)) { + return fastApiCliCache.get(pythonPath)!; + } + const promise = plainExec(pythonPath, ['-c', 'import fastapi_cli'], { throwOnStdErr: false }) + .then(() => true) + .catch(() => false); + fastApiCliCache.set(pythonPath, promise); + return promise; +} + 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/providers/fastapiLaunch.unit.test.ts b/src/test/unittest/configuration/providers/fastapiLaunch.unit.test.ts index aa8711e4..c5b5c177 100644 --- a/src/test/unittest/configuration/providers/fastapiLaunch.unit.test.ts +++ b/src/test/unittest/configuration/providers/fastapiLaunch.unit.test.ts @@ -31,7 +31,28 @@ suite('Debugging - Configuration Provider FastAPI', () => { type: DebuggerTypeName, request: 'launch', module: 'fastapi', - args: ['run'], + args: ['dev'], + jinja: true, + subProcess: true, + }; + + expect(state.config).to.be.deep.equal(config); + }); + + 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: ['dev', '${file}'], + jinja: true, + subProcess: true, }; expect(state.config).to.be.deep.equal(config); From f855c113cd9b4867db22825485648ac931e9d2e9 Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Wed, 27 May 2026 13:25:44 -0700 Subject: [PATCH 3/9] Cleanup --- .../dynamicdebugConfigurationService.ts | 51 +++++++------------ .../configuration/providers/fastapiLaunch.ts | 6 +-- .../configuration/utils/configuration.ts | 19 ------- .../providers/fastapiLaunch.unit.test.ts | 6 +-- 4 files changed, 21 insertions(+), 61 deletions(-) diff --git a/src/extension/debugger/configuration/dynamicdebugConfigurationService.ts b/src/extension/debugger/configuration/dynamicdebugConfigurationService.ts index f3ff7412..1920c822 100644 --- a/src/extension/debugger/configuration/dynamicdebugConfigurationService.ts +++ b/src/extension/debugger/configuration/dynamicdebugConfigurationService.ts @@ -8,10 +8,9 @@ import * as path from 'path'; import { CancellationToken, DebugConfiguration, WorkspaceFolder } from 'vscode'; import { IDynamicDebugConfigurationService } from '../types'; import { DebuggerTypeName } from '../../constants'; -import { getDjangoPaths, getFastApiPaths, getFlaskPaths, isFastApiCliAvailable } from './utils/configuration'; +import { getDjangoPaths, getFastApiPaths, getFlaskPaths } from './utils/configuration'; import { sendTelemetryEvent } from '../../telemetry'; import { EventName } from '../../telemetry/constants'; -import { replaceAll } from '../../common/stringUtils'; const workspaceFolderToken = '${workspaceFolder}'; @@ -64,38 +63,22 @@ export class DynamicPythonDebugConfigurationService implements IDynamicDebugConf const fastApiPaths = await getFastApiPaths(folder); if (fastApiPaths?.length) { - const hasFastApiCli = await isFastApiCliAvailable(folder.uri); - if (hasFastApiCli) { - providers.push({ - name: 'Python Debugger: FastAPI', - type: DebuggerTypeName, - request: 'launch', - module: 'fastapi', - args: ['dev'], - jinja: true, - subProcess: true, - }); - providers.push({ - name: 'Python Debugger: FastAPI File', - type: DebuggerTypeName, - request: 'launch', - module: 'fastapi', - args: ['dev', '${file}'], - jinja: true, - subProcess: true, - }); - } else { - // Legacy fallback when fastapi-cli is not available. - const fastApiPath = replaceAll(path.relative(folder.uri.fsPath, fastApiPaths[0].fsPath), path.sep, '.').replace('.py', ''); - providers.push({ - name: 'Python Debugger: FastAPI', - type: DebuggerTypeName, - request: 'launch', - module: 'uvicorn', - args: [`${fastApiPath}:app`, '--reload'], - jinja: true, - }); - } + providers.push({ + name: 'Python Debugger: FastAPI', + type: DebuggerTypeName, + request: 'launch', + module: 'fastapi', + args: ['run'], + jinja: true, + }); + providers.push({ + name: 'Python Debugger: FastAPI File', + type: DebuggerTypeName, + request: 'launch', + module: 'fastapi', + args: ['run', '${file}'], + jinja: true, + }); } sendTelemetryEvent(EventName.DEBUGGER_DYNAMIC_CONFIGURATION, undefined, { providers: providers }); diff --git a/src/extension/debugger/configuration/providers/fastapiLaunch.ts b/src/extension/debugger/configuration/providers/fastapiLaunch.ts index de4b2539..909da7c6 100644 --- a/src/extension/debugger/configuration/providers/fastapiLaunch.ts +++ b/src/extension/debugger/configuration/providers/fastapiLaunch.ts @@ -20,9 +20,8 @@ export async function buildFastAPILaunchDebugConfiguration( type: DebuggerTypeName, request: 'launch', module: 'fastapi', - args: ['dev'], + args: ['run'], jinja: true, - subProcess: true, }; sendTelemetryEvent(EventName.DEBUGGER_CONFIGURATION_PROMPTS, undefined, { configurationType: DebugConfigurationType.launchFastAPI, @@ -39,9 +38,8 @@ export async function buildFastAPIWithFileLaunchDebugConfiguration( type: DebuggerTypeName, request: 'launch', module: 'fastapi', - args: ['dev', '${file}'], + args: ['run', '${file}'], jinja: true, - subProcess: true, }; sendTelemetryEvent(EventName.DEBUGGER_CONFIGURATION_PROMPTS, undefined, { configurationType: DebugConfigurationType.launchFastAPIWithFile, diff --git a/src/extension/debugger/configuration/utils/configuration.ts b/src/extension/debugger/configuration/utils/configuration.ts index ed13b67b..a3af3681 100644 --- a/src/extension/debugger/configuration/utils/configuration.ts +++ b/src/extension/debugger/configuration/utils/configuration.ts @@ -15,11 +15,8 @@ import { AttachRequestArguments } from '../../../types'; import { DebugConfigurationState, DebugConfigurationType } from '../../types'; import { Uri, WorkspaceFolder, workspace } from 'vscode'; import { asyncFilter } from '../../../common/utilities'; -import { getInterpreterDetails } from '../../../common/python'; -import { plainExec } from '../../../common/process/rawProcessApis'; const defaultPort = 5678; -const fastApiCliCache = new Map>(); export async function configurePort( input: MultiStepInput, @@ -84,22 +81,6 @@ export async function getFastApiPaths(folder: WorkspaceFolder | undefined) { return fastApiPaths; } -export async function isFastApiCliAvailable(resource?: Uri): Promise { - const interpreterDetails = await getInterpreterDetails(resource); - const pythonPath = interpreterDetails?.path?.[0]; - if (!pythonPath) { - return false; - } - if (fastApiCliCache.has(pythonPath)) { - return fastApiCliCache.get(pythonPath)!; - } - const promise = plainExec(pythonPath, ['-c', 'import fastapi_cli'], { throwOnStdErr: false }) - .then(() => true) - .catch(() => false); - fastApiCliCache.set(pythonPath, promise); - return promise; -} - export async function getFlaskPaths(folder: WorkspaceFolder | undefined) { if (!folder) { return []; diff --git a/src/test/unittest/configuration/providers/fastapiLaunch.unit.test.ts b/src/test/unittest/configuration/providers/fastapiLaunch.unit.test.ts index c5b5c177..51d5e9df 100644 --- a/src/test/unittest/configuration/providers/fastapiLaunch.unit.test.ts +++ b/src/test/unittest/configuration/providers/fastapiLaunch.unit.test.ts @@ -31,9 +31,8 @@ suite('Debugging - Configuration Provider FastAPI', () => { type: DebuggerTypeName, request: 'launch', module: 'fastapi', - args: ['dev'], + args: ['run'], jinja: true, - subProcess: true, }; expect(state.config).to.be.deep.equal(config); @@ -50,9 +49,8 @@ suite('Debugging - Configuration Provider FastAPI', () => { type: DebuggerTypeName, request: 'launch', module: 'fastapi', - args: ['dev', '${file}'], + args: ['run', '${file}'], jinja: true, - subProcess: true, }; expect(state.config).to.be.deep.equal(config); From 94c97b077113de42a3cbc4d8ac27c82a4ac08a4f Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Wed, 27 May 2026 13:54:46 -0700 Subject: [PATCH 4/9] Add newline at the end of file --- src/extension/debugger/configuration/providers/fastapiLaunch.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/extension/debugger/configuration/providers/fastapiLaunch.ts b/src/extension/debugger/configuration/providers/fastapiLaunch.ts index 909da7c6..854e2f43 100644 --- a/src/extension/debugger/configuration/providers/fastapiLaunch.ts +++ b/src/extension/debugger/configuration/providers/fastapiLaunch.ts @@ -45,4 +45,4 @@ export async function buildFastAPIWithFileLaunchDebugConfiguration( configurationType: DebugConfigurationType.launchFastAPIWithFile, }); Object.assign(state.config, config); -} \ No newline at end of file +} From dbe5c8103e506dfe015017a40af924d360ec2cfc Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Mon, 1 Jun 2026 11:45:11 -0700 Subject: [PATCH 5/9] Refactor for better detection UX --- src/extension/common/utils/localize.ts | 5 ++ .../dynamicdebugConfigurationService.ts | 5 +- .../configuration/providers/fastapiLaunch.ts | 41 ++++++++- .../configuration/utils/configuration.ts | 9 ++ .../providers/fastapiLaunch.unit.test.ts | 88 ++++++++++++++++++- 5 files changed, 141 insertions(+), 7 deletions(-) diff --git a/src/extension/common/utils/localize.ts b/src/extension/common/utils/localize.ts index 2e94559e..5c61c3aa 100644 --- a/src/extension/common/utils/localize.ts +++ b/src/extension/common/utils/localize.ts @@ -118,6 +118,11 @@ export namespace DebugConfigStrings { 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 your FastAPI app (e.g. main.py or backend/app/main.py).'), + invalid: l10n.t('Enter a valid path'), + }; } export namespace flask { export const snippet = { diff --git a/src/extension/debugger/configuration/dynamicdebugConfigurationService.ts b/src/extension/debugger/configuration/dynamicdebugConfigurationService.ts index 1920c822..f3903106 100644 --- a/src/extension/debugger/configuration/dynamicdebugConfigurationService.ts +++ b/src/extension/debugger/configuration/dynamicdebugConfigurationService.ts @@ -8,7 +8,7 @@ import * as path from 'path'; import { CancellationToken, DebugConfiguration, WorkspaceFolder } from 'vscode'; import { IDynamicDebugConfigurationService } from '../types'; import { DebuggerTypeName } from '../../constants'; -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,12 +63,13 @@ export class DynamicPythonDebugConfigurationService implements IDynamicDebugConf const fastApiPaths = await getFastApiPaths(folder); if (fastApiPaths?.length) { + const fastApiArgs = tryResolveFastApiArgs(folder, fastApiPaths) ?? ['run']; providers.push({ name: 'Python Debugger: FastAPI', type: DebuggerTypeName, request: 'launch', module: 'fastapi', - args: ['run'], + args: fastApiArgs, jinja: true, }); providers.push({ diff --git a/src/extension/debugger/configuration/providers/fastapiLaunch.ts b/src/extension/debugger/configuration/providers/fastapiLaunch.ts index 854e2f43..3e61629f 100644 --- a/src/extension/debugger/configuration/providers/fastapiLaunch.ts +++ b/src/extension/debugger/configuration/providers/fastapiLaunch.ts @@ -3,6 +3,7 @@ 'use strict'; +import * as path from 'path'; import { MultiStepInput } from '../../../common/multiStepInput'; import { DebugConfigStrings } from '../../../common/utils/localize'; import { sendTelemetryEvent } from '../../../telemetry'; @@ -10,17 +11,53 @@ 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, + input: MultiStepInput, state: DebugConfigurationState, ): Promise { + const fastApiPaths = await getFastApiPaths(state.folder); + const autoArgs = state.folder ? tryResolveFastApiArgs(state.folder, fastApiPaths) : undefined; + + let args: string[]; + 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]; + } + const config: Partial = { name: DebugConfigStrings.fastapi.snippet.name, type: DebuggerTypeName, request: 'launch', module: 'fastapi', - args: ['run'], + args, jinja: true, }; sendTelemetryEvent(EventName.DEBUGGER_CONFIGURATION_PROMPTS, undefined, { diff --git a/src/extension/debugger/configuration/utils/configuration.ts b/src/extension/debugger/configuration/utils/configuration.ts index a3af3681..791aa59b 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: Uri[]): string[] | undefined { + if (paths.length !== 1) { + return undefined; + } + const relative = path.relative(folder.uri.fsPath, paths[0].fsPath); + return relative.includes(path.sep) ? ['run', relative] : ['run']; +} + export async function getFlaskPaths(folder: WorkspaceFolder | undefined) { if (!folder) { return []; diff --git a/src/test/unittest/configuration/providers/fastapiLaunch.unit.test.ts b/src/test/unittest/configuration/providers/fastapiLaunch.unit.test.ts index 51d5e9df..c3768c8b 100644 --- a/src/test/unittest/configuration/providers/fastapiLaunch.unit.test.ts +++ b/src/test/unittest/configuration/providers/fastapiLaunch.unit.test.ts @@ -4,25 +4,34 @@ 'use strict'; import { expect } from 'chai'; -import { instance, mock } from 'ts-mockito'; -import { Uri } from 'vscode'; import * as path from 'path'; +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 getFastApiPathsStub: sinon.SinonStub; setup(() => { input = mock>(MultiStepInput); + getFastApiPathsStub = sinon.stub(configurationUtils, 'getFastApiPaths'); + }); + + teardown(() => { + sinon.restore(); }); - test('Launch JSON with default configuration', async () => { + test('Single match at workspace root → plain `fastapi run`', 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', 'main.py'))]); await fastApiLaunch.buildFastAPILaunchDebugConfiguration(instance(input), state); @@ -38,6 +47,79 @@ suite('Debugging - Configuration Provider FastAPI', () => { expect(state.config).to.be.deep.equal(config); }); + test('Single match in subdirectory → passes path explicitly', 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', '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(state.config).to.be.deep.equal(config); + }); + + 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); + + 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); + + const config = { + name: DebugConfigStrings.fastapi.snippet.name, + type: DebuggerTypeName, + request: 'launch', + 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 }; From 78d8e1ca502f721f8c1706375b30e0c67d9e550d Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Mon, 1 Jun 2026 11:45:39 -0700 Subject: [PATCH 6/9] Formatting --- .../configuration/debugConfigurationService.ts | 10 ++++++++-- .../debugger/configuration/providers/fastapiLaunch.ts | 8 ++------ 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/extension/debugger/configuration/debugConfigurationService.ts b/src/extension/debugger/configuration/debugConfigurationService.ts index 79d55188..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, buildFastAPIWithFileLaunchDebugConfiguration } from './providers/fastapiLaunch'; +import { + buildFastAPILaunchDebugConfiguration, + buildFastAPIWithFileLaunchDebugConfiguration, +} from './providers/fastapiLaunch'; import { buildFileLaunchDebugConfiguration } from './providers/fileLaunch'; import { buildFlaskLaunchDebugConfiguration } from './providers/flaskLaunch'; import { buildModuleLaunchConfiguration } from './providers/moduleLaunch'; @@ -183,7 +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.launchFastAPIWithFile, + buildFastAPIWithFileLaunchDebugConfiguration, + ); debugConfigurations.set(DebugConfigurationType.launchFile, buildFileLaunchDebugConfiguration); debugConfigurations.set(DebugConfigurationType.launchFileWithArgs, buildFileWithArgsLaunchDebugConfiguration); debugConfigurations.set(DebugConfigurationType.launchFlask, buildFlaskLaunchDebugConfiguration); diff --git a/src/extension/debugger/configuration/providers/fastapiLaunch.ts b/src/extension/debugger/configuration/providers/fastapiLaunch.ts index 3e61629f..c0dae6da 100644 --- a/src/extension/debugger/configuration/providers/fastapiLaunch.ts +++ b/src/extension/debugger/configuration/providers/fastapiLaunch.ts @@ -22,9 +22,7 @@ async function promptForAppPath( prompt: DebugConfigStrings.fastapi.enterAppPath.prompt, value: value ?? '', validate: (v) => - Promise.resolve( - v && v.trim().length > 0 ? undefined : DebugConfigStrings.fastapi.enterAppPath.invalid, - ), + Promise.resolve(v && v.trim().length > 0 ? undefined : DebugConfigStrings.fastapi.enterAppPath.invalid), }); return entered?.trim(); } @@ -42,9 +40,7 @@ export async function buildFastAPILaunchDebugConfiguration( } else { const workspaceRoot = state.folder?.uri.fsPath; const prefill = - workspaceRoot && fastApiPaths.length > 0 - ? path.relative(workspaceRoot, fastApiPaths[0].fsPath) - : undefined; + workspaceRoot && fastApiPaths.length > 0 ? path.relative(workspaceRoot, fastApiPaths[0].fsPath) : undefined; const entered = await promptForAppPath(input, prefill); if (!entered) { return; From 86797072658287733d5ed6a801e5d3ac15b15364 Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Mon, 1 Jun 2026 12:28:27 -0700 Subject: [PATCH 7/9] Simplify resolve path --- src/extension/debugger/configuration/utils/configuration.ts | 2 +- .../configuration/providers/fastapiLaunch.unit.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/extension/debugger/configuration/utils/configuration.ts b/src/extension/debugger/configuration/utils/configuration.ts index 791aa59b..54c71cc5 100644 --- a/src/extension/debugger/configuration/utils/configuration.ts +++ b/src/extension/debugger/configuration/utils/configuration.ts @@ -87,7 +87,7 @@ export function tryResolveFastApiArgs(folder: WorkspaceFolder, paths: Uri[]): st return undefined; } const relative = path.relative(folder.uri.fsPath, paths[0].fsPath); - return relative.includes(path.sep) ? ['run', relative] : ['run']; + return ['run', relative]; } export async function getFlaskPaths(folder: WorkspaceFolder | undefined) { diff --git a/src/test/unittest/configuration/providers/fastapiLaunch.unit.test.ts b/src/test/unittest/configuration/providers/fastapiLaunch.unit.test.ts index c3768c8b..4dd842f3 100644 --- a/src/test/unittest/configuration/providers/fastapiLaunch.unit.test.ts +++ b/src/test/unittest/configuration/providers/fastapiLaunch.unit.test.ts @@ -28,7 +28,7 @@ suite('Debugging - Configuration Provider FastAPI', () => { sinon.restore(); }); - test('Single match at workspace root → plain `fastapi run`', 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 state = { config: {}, folder }; getFastApiPathsStub.resolves([Uri.parse(path.join('one', 'two', 'main.py'))]); @@ -40,7 +40,7 @@ suite('Debugging - Configuration Provider FastAPI', () => { type: DebuggerTypeName, request: 'launch', module: 'fastapi', - args: ['run'], + args: ['run', 'main.py'], jinja: true, }; From e8179cac7ad5f7696a5117f7f06cbbb2db6e0900 Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Mon, 1 Jun 2026 12:41:04 -0700 Subject: [PATCH 8/9] Address comments --- .../configuration/providers/fastapiLaunch.ts | 4 ++ .../configuration/utils/configuration.ts | 2 +- ...amicdebugConfigurationService.unit.test.ts | 65 +++++++++++++++++++ .../providers/fastapiLaunch.unit.test.ts | 33 ++++++++++ 4 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 src/test/unittest/configuration/dynamicdebugConfigurationService.unit.test.ts diff --git a/src/extension/debugger/configuration/providers/fastapiLaunch.ts b/src/extension/debugger/configuration/providers/fastapiLaunch.ts index c0dae6da..9e49ccb0 100644 --- a/src/extension/debugger/configuration/providers/fastapiLaunch.ts +++ b/src/extension/debugger/configuration/providers/fastapiLaunch.ts @@ -35,6 +35,7 @@ export async function buildFastAPILaunchDebugConfiguration( const autoArgs = state.folder ? tryResolveFastApiArgs(state.folder, fastApiPaths) : undefined; let args: string[]; + let manuallyEnteredAValue = false; if (autoArgs) { args = autoArgs; } else { @@ -46,6 +47,7 @@ export async function buildFastAPILaunchDebugConfiguration( return; } args = ['run', entered]; + manuallyEnteredAValue = true; } const config: Partial = { @@ -58,6 +60,8 @@ export async function buildFastAPILaunchDebugConfiguration( }; sendTelemetryEvent(EventName.DEBUGGER_CONFIGURATION_PROMPTS, undefined, { configurationType: DebugConfigurationType.launchFastAPI, + autoDetectedFastAPIMainPyPath: !!autoArgs, + manuallyEnteredAValue, }); Object.assign(state.config, config); } diff --git a/src/extension/debugger/configuration/utils/configuration.ts b/src/extension/debugger/configuration/utils/configuration.ts index 54c71cc5..33281396 100644 --- a/src/extension/debugger/configuration/utils/configuration.ts +++ b/src/extension/debugger/configuration/utils/configuration.ts @@ -82,7 +82,7 @@ export async function getFastApiPaths(folder: WorkspaceFolder | undefined) { return fastApiPaths; } -export function tryResolveFastApiArgs(folder: WorkspaceFolder, paths: Uri[]): string[] | undefined { +export function tryResolveFastApiArgs(folder: WorkspaceFolder, paths: readonly Uri[]): string[] | undefined { if (paths.length !== 1) { return undefined; } 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..a752146d --- /dev/null +++ b/src/test/unittest/configuration/dynamicdebugConfigurationService.unit.test.ts @@ -0,0 +1,65 @@ +// 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 { 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')); + }; + + 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.include({ name: 'Python Debugger: FastAPI', module: 'fastapi' }); + expect(fastApi[0].args).to.deep.equal(['run', 'main.py']); + expect(fastApi[1]).to.include({ name: 'Python Debugger: FastAPI File', module: 'fastapi' }); + expect(fastApi[1].args).to.deep.equal(['run', '${file}']); + }); + + 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[0].args).to.deep.equal(['run', path.join('backend', 'app', 'main.py')]); + }); + + 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[0].args).to.deep.equal(['run']); + }); +}); diff --git a/src/test/unittest/configuration/providers/fastapiLaunch.unit.test.ts b/src/test/unittest/configuration/providers/fastapiLaunch.unit.test.ts index 4dd842f3..7511b102 100644 --- a/src/test/unittest/configuration/providers/fastapiLaunch.unit.test.ts +++ b/src/test/unittest/configuration/providers/fastapiLaunch.unit.test.ts @@ -138,3 +138,36 @@ suite('Debugging - Configuration Provider FastAPI', () => { 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'), + ]); + }); +}); From 46a83ead7bee6cc881a652cc47796f595f5b3d6a Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Mon, 1 Jun 2026 13:11:28 -0700 Subject: [PATCH 9/9] deep.equal asserts --- ...amicdebugConfigurationService.unit.test.ts | 45 ++++++++++++++++--- 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/src/test/unittest/configuration/dynamicdebugConfigurationService.unit.test.ts b/src/test/unittest/configuration/dynamicdebugConfigurationService.unit.test.ts index a752146d..710f9823 100644 --- a/src/test/unittest/configuration/dynamicdebugConfigurationService.unit.test.ts +++ b/src/test/unittest/configuration/dynamicdebugConfigurationService.unit.test.ts @@ -7,6 +7,7 @@ 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'; @@ -31,6 +32,15 @@ suite('Debugging - Dynamic Debug Configuration Service', () => { 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([]); @@ -43,23 +53,46 @@ suite('Debugging - Dynamic Debug Configuration Service', () => { const fastApi = await fastApiProviders(); expect(fastApi).to.have.length(2); - expect(fastApi[0]).to.include({ name: 'Python Debugger: FastAPI', module: 'fastapi' }); - expect(fastApi[0].args).to.deep.equal(['run', 'main.py']); - expect(fastApi[1]).to.include({ name: 'Python Debugger: FastAPI File', module: 'fastapi' }); - expect(fastApi[1].args).to.deep.equal(['run', '${file}']); + 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[0].args).to.deep.equal(['run', path.join('backend', 'app', 'main.py')]); + 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[0].args).to.deep.equal(['run']); + 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); }); });