Skip to content
13 changes: 10 additions & 3 deletions src/extension/common/utils/localize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -63,15 +62,22 @@ export class DynamicPythonDebugConfigurationService implements IDynamicDebugConf
}

const fastApiPaths = await getFastApiPaths(folder);
Comment thread
savannahostrowski marked this conversation as resolved.
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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "FastAPI File" (${file}) variant is only pushed inside if (fastApiPaths?.length), so a user whose app doesn't match the app\s*=\s*FastAPI\( regex (e.g. application = FastAPI(), a factory pattern, or app in __init__.py) gets neither config — even though fastapi run ${file} would work on their open file. The ${file} variant doesn't depend on project detection; consider offering it independently of the detection gate.

});
}
Expand Down
91 changes: 54 additions & 37 deletions src/extension/debugger/configuration/providers/fastapiLaunch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,65 +4,82 @@
'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';
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<DebugConfigurationState>,
value?: string,
): Promise<string | undefined> {
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<DebugConfigurationState>,
state: DebugConfigurationState,
): Promise<void> {
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<LaunchRequestArguments> = {
Comment thread
savannahostrowski marked this conversation as resolved.
name: DebugConfigStrings.fastapi.snippet.name,
type: DebuggerTypeName,
request: 'launch',
module: 'uvicorn',
args: ['main:app', '--reload'],
module: 'fastapi',
args,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

module: 'fastapi' runs python -m fastapi run …, which requires fastapi-cli (fastapi[standard]) or FastAPI ≥ 0.111. The previous module: 'uvicorn' worked with a bare uvicorn install, so environments that debugged fine before can now fail immediately with No module named fastapi.__main__. Consider detecting CLI availability or surfacing an actionable message, and document the minimum FastAPI/fastapi-cli requirement.

jinja: true,
};

if (!application) {
Comment thread
savannahostrowski marked this conversation as resolved.
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<string | undefined> {
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<DebugConfigurationState>,
state: DebugConfigurationState,
): Promise<void> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot generated:
The Architect notes the ['run', '${file}'] config literal now lives in two places (here and dynamicdebugConfigurationService.ts), and the ['run'] default is encoded both in tryResolveFastApiArgs and as ?? ['run'] in the dynamic caller. Hoist to a shared constant / small helper to prevent drift. Low priority.

[verified]

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried this but extracting ['run', '${file}'] into a readonly string[] constant required spreading at every call site ([...FASTAPI_RUN_FILE_ARGS]), which was longer than the inline literal.

const config: Partial<LaunchRequestArguments> = {
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);
}
9 changes: 9 additions & 0 deletions src/extension/debugger/configuration/utils/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -81,6 +82,14 @@ export async function getFastApiPaths(folder: WorkspaceFolder | undefined) {
return fastApiPaths;
}

export function tryResolveFastApiArgs(folder: WorkspaceFolder, paths: readonly Uri[]): string[] | undefined {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot generated:
Unresolved from prior review (Medium): path.relative emits OS-native separators, so a single subdir match persists "args": ["run", "backend\\app\\main.py"] on Windows. A launch.json shared to macOS/Linux then runs fastapi run backend\app\main.py, which treats backslashes literally and fails despite correct detection. The Skeptic notes the central fix actually broadened exposure here (root single-matches previously emitted separator-free ['run']). Normalize to POSIX in the persisted arg (e.g. relative.split(path.sep).join('/')) and confirm fastapi run accepts forward slashes on Windows.

[verified]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Savannah Ostrowski (@savannahostrowski) what did you think of this idea? Does fastapi work with forward slashes on Windows?

if (paths.length !== 1) {
return undefined;
}
const relative = path.relative(folder.uri.fsPath, paths[0].fsPath);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

path.relative yields OS-native separators (e.g. backend\app\main.py on Windows) that get baked into args and persisted to launch.json. A committed launch.json should be portable; consider normalizing to POSIX separators (fastapi run accepts / on Windows) so a shared config works cross-platform.

return ['run', relative];
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Multi-root workspace edge case: getFastApiPaths uses workspace-wide workspace.findFiles, so tryResolveFastApiArgs(folder, paths) can compute path.relative(folder, fileInAnotherFolder) → ..\otherFolder\main.py, which is then passed straight to fastapi run and points outside the folder. Consider filtering detected paths to the target folder, or treating cross-folder matches as "not a single match" and falling back to plain run.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tryResolveFastApiArgs returns undefined for BOTH "0 matches" and ">1 matches", and the two callers resolve that ambiguity differently (dynamic provider → ?? ['run']; interactive builder → prompt). This is intentional and tested, but undocumented. Add a one-line comment stating it returns args only for an unambiguous single match and that callers decide how to handle 0-or-many, so a future maintainer doesn't "fix" it and break one path.


export async function getFlaskPaths(folder: WorkspaceFolder | undefined) {
if (!folder) {
return [];
Expand Down
1 change: 1 addition & 0 deletions src/extension/debugger/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export enum DebugConfigurationType {
remoteAttach = 'remoteAttach',
launchDjango = 'launchDjango',
launchFastAPI = 'launchFastAPI',
launchFastAPIWithFile = 'launchFastAPIWithFile',
launchFlask = 'launchFlask',
launchModule = 'launchModule',
launchPyramid = 'launchPyramid',
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
Comment thread
savannahostrowski marked this conversation as resolved.
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);
});
});
Loading
Loading