Skip to content

Commit f1d0c60

Browse files
authored
feat: FastAPI debugger (#14606)
* feat: fastapi debugger * feat: fastapi debugger * add tests * add news * fix vscode settings * remove eslinter ignore * extract filename without extension from path.basename * remove linter ignore from test file * fix linter * change python version for fastapi * change python version for uvicorn * add coverage to base.ts * remove unused linter ignore comment * fix name of variable that reflects main.py location
1 parent 06c9b9e commit f1d0c60

File tree

18 files changed

+253
-4
lines changed

18 files changed

+253
-4
lines changed

.eslintignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -847,6 +847,7 @@ src/client/common/insidersBuild/downloadChannelService.ts
847847
src/client/common/insidersBuild/downloadChannelRules.ts
848848

849849
src/client/debugger/extension/configuration/providers/moduleLaunch.ts
850+
src/client/debugger/extension/configuration/providers/fastapiLaunch.ts
850851
src/client/debugger/extension/configuration/providers/flaskLaunch.ts
851852
src/client/debugger/extension/configuration/providers/fileLaunch.ts
852853
src/client/debugger/extension/configuration/providers/remoteAttach.ts

build/conda-functional-requirements.txt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ pydocstyle
1212
nose
1313
pytest==4.6.9 # Last version of pytest with Python 2.7 support
1414
rope
15+
fastapi ; python_version>='3.6'
16+
uvicorn ; python_version>='3.6'
1517
flask
1618
django
1719
isort
@@ -23,4 +25,4 @@ beakerx
2325
py4j
2426
bqplot
2527
K3D
26-
debugpy
28+
debugpy

build/test-requirements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ nose
1313
pytest<6 ; python_version > '2.7' # Tests currently fail against pytest 6.
1414
rope
1515
flask
16+
fastapi ; python_version > '2.7'
17+
uvicorn ; python_version > '2.7'
1618
django
1719
isort
1820

news/1 Enhancements/14247.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
FastAPI debugger feature.
2+
(thanks [Marcelo Trylesinski](https://github.com/kludex/))

package.nls.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
"python.snippet.launch.attach.label": "Python: Remote Attach",
4343
"python.snippet.launch.attachpid.label": "Python: Attach using Process Id",
4444
"python.snippet.launch.django.label": "Python: Django",
45+
"python.snippet.launch.fastapi.label": "Python: FastAPI",
4546
"python.snippet.launch.flask.label": "Python: Flask",
4647
"python.snippet.launch.pyramid.label": "Python: Pyramid Application",
4748
"Pylance.proposePylanceMessage": "Try out a new faster, feature-rich language server for Python by Microsoft, Pylance! Install the extension now.",
@@ -107,8 +108,7 @@
107108
"Installer.noCondaOrPipInstaller": "There is no Conda or Pip installer available in the selected environment.",
108109
"Installer.noPipInstaller": "There is no Pip installer available in the selected environment.",
109110
"Installer.searchForHelp": "Search for help",
110-
"Installer.couldNotInstallLibrary":
111-
"Could not install {0}. If pip is not available, please use the package manager of your choice to manually install this library into your Python environment.",
111+
"Installer.couldNotInstallLibrary": "Could not install {0}. If pip is not available, please use the package manager of your choice to manually install this library into your Python environment.",
112112
"Installer.dataScienceInstallPrompt": "Data Science library {0} is not installed. Install?",
113113
"diagnostics.removedPythonPathFromSettings": "We removed the \"python.pythonPath\" setting from your settings.json file as the setting is no longer used by the Python extension. You can get the path of your selected interpreter in the Python output channel. [Learn more](https://aka.ms/AA7jfor).",
114114
"diagnostics.warnSourceMaps": "Source map support is enabled in the Python Extension, this will adversely impact performance of the extension.",
@@ -155,6 +155,11 @@
155155
"debug.djangoEnterManagePyPathTitle": "Debug Django",
156156
"debug.djangoEnterManagePyPathPrompt": "Enter the path to manage.py ('${workspaceFolderToken}' points to the root of the current workspace folder)",
157157
"debug.djangoEnterManagePyPathInvalidFilePathError": "Enter a valid Python file path",
158+
"debug.debugFastAPIConfigurationLabel": "FastAPI",
159+
"debug.debugFastAPIConfigurationDescription": "Launch and debug a FastAPI web application",
160+
"debug.fastapiEnterAppPathOrNamePathTitle": "Debug FastAPI",
161+
"debug.fastapiEnterAppPathOrNamePathPrompt": "Enter the path to the application, e.g. 'main.py' or 'main'",
162+
"debug.fastapiEnterAppPathOrNamePathInvalidNameError": "Enter a valid name",
158163
"debug.debugFlaskConfigurationLabel": "Flask",
159164
"debug.debugFlaskConfigurationDescription": "Launch and debug a Flask web application",
160165
"debug.flaskEnterAppPathOrNamePathTitle": "Debug Flask",

src/client/common/utils/localize.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,6 +453,21 @@ export namespace DebugConfigStrings {
453453
invalid: localize('debug.djangoEnterManagePyPathInvalidFilePathError')
454454
};
455455
}
456+
export namespace fastapi {
457+
export const snippet = {
458+
name: localize('python.snippet.launch.fastapi.label')
459+
};
460+
// tslint:disable-next-line:no-shadowed-variable
461+
export const selectConfiguration = {
462+
label: localize('debug.debugFastAPIConfigurationLabel'),
463+
description: localize('debug.debugFastAPIConfigurationDescription')
464+
};
465+
export const enterAppPathOrNamePath = {
466+
title: localize('debug.fastapiEnterAppPathOrNamePathTitle'),
467+
prompt: localize('debug.fastapiEnterAppPathOrNamePathPrompt'),
468+
invalid: localize('debug.fastapiEnterAppPathOrNamePathInvalidNameError')
469+
};
470+
}
456471
export namespace flask {
457472
export const snippet = {
458473
name: localize('python.snippet.launch.flask.label')

src/client/debugger/extension/configuration/debugConfigurationService.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,11 @@ export class PythonDebugConfigurationService implements IDebugConfigurationServi
121121
type: DebugConfigurationType.launchDjango,
122122
description: DebugConfigStrings.django.selectConfiguration.description()
123123
},
124+
{
125+
label: DebugConfigStrings.fastapi.selectConfiguration.label(),
126+
type: DebugConfigurationType.launchFastAPI,
127+
description: DebugConfigStrings.fastapi.selectConfiguration.description()
128+
},
124129
{
125130
label: DebugConfigStrings.flask.selectConfiguration.label(),
126131
type: DebugConfigurationType.launchFlask,
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
'use strict';
5+
6+
import { inject, injectable } from 'inversify';
7+
import * as path from 'path';
8+
import { WorkspaceFolder } from 'vscode';
9+
import { IFileSystem } from '../../../../common/platform/types';
10+
import { DebugConfigStrings } from '../../../../common/utils/localize';
11+
import { MultiStepInput } from '../../../../common/utils/multiStepInput';
12+
import { sendTelemetryEvent } from '../../../../telemetry';
13+
import { EventName } from '../../../../telemetry/constants';
14+
import { DebuggerTypeName } from '../../../constants';
15+
import { LaunchRequestArguments } from '../../../types';
16+
import { DebugConfigurationState, DebugConfigurationType, IDebugConfigurationProvider } from '../../types';
17+
18+
@injectable()
19+
export class FastAPILaunchDebugConfigurationProvider implements IDebugConfigurationProvider {
20+
constructor(@inject(IFileSystem) private fs: IFileSystem) {}
21+
public isSupported(debugConfigurationType: DebugConfigurationType): boolean {
22+
return debugConfigurationType === DebugConfigurationType.launchFastAPI;
23+
}
24+
public async buildConfiguration(input: MultiStepInput<DebugConfigurationState>, state: DebugConfigurationState) {
25+
const application = await this.getApplicationPath(state.folder);
26+
let manuallyEnteredAValue: boolean | undefined;
27+
const config: Partial<LaunchRequestArguments> = {
28+
name: DebugConfigStrings.fastapi.snippet.name(),
29+
type: DebuggerTypeName,
30+
request: 'launch',
31+
module: 'uvicorn',
32+
args: ['main:app'],
33+
jinja: true
34+
};
35+
36+
if (!application) {
37+
const selectedPath = await input.showInputBox({
38+
title: DebugConfigStrings.fastapi.enterAppPathOrNamePath.title(),
39+
value: 'main.py',
40+
prompt: DebugConfigStrings.fastapi.enterAppPathOrNamePath.prompt(),
41+
validate: (value) =>
42+
Promise.resolve(
43+
value && value.trim().length > 0
44+
? undefined
45+
: DebugConfigStrings.fastapi.enterAppPathOrNamePath.invalid()
46+
)
47+
});
48+
if (selectedPath) {
49+
manuallyEnteredAValue = true;
50+
config.args = [`${path.basename(selectedPath, '.py').replace('/', '.')}:app`];
51+
}
52+
}
53+
54+
sendTelemetryEvent(EventName.DEBUGGER_CONFIGURATION_PROMPTS, undefined, {
55+
configurationType: DebugConfigurationType.launchFastAPI,
56+
autoDetectedFastAPIMainPyPath: !!application,
57+
manuallyEnteredAValue
58+
});
59+
Object.assign(state.config, config);
60+
}
61+
protected async getApplicationPath(folder: WorkspaceFolder | undefined): Promise<string | undefined> {
62+
if (!folder) {
63+
return;
64+
}
65+
const defaultLocationOfManagePy = path.join(folder.uri.fsPath, 'main.py');
66+
if (await this.fs.fileExists(defaultLocationOfManagePy)) {
67+
return 'main.py';
68+
}
69+
}
70+
}

src/client/debugger/extension/configuration/providers/providerFactory.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ import { IDebugConfigurationProviderFactory } from '../types';
1111
export class DebugConfigurationProviderFactory implements IDebugConfigurationProviderFactory {
1212
private readonly providers: Map<DebugConfigurationType, IDebugConfigurationProvider>;
1313
constructor(
14+
@inject(IDebugConfigurationProvider)
15+
@named(DebugConfigurationType.launchFastAPI)
16+
fastapiProvider: IDebugConfigurationProvider,
1417
@inject(IDebugConfigurationProvider)
1518
@named(DebugConfigurationType.launchFlask)
1619
flaskProvider: IDebugConfigurationProvider,
@@ -35,6 +38,7 @@ export class DebugConfigurationProviderFactory implements IDebugConfigurationPro
3538
) {
3639
this.providers = new Map<DebugConfigurationType, IDebugConfigurationProvider>();
3740
this.providers.set(DebugConfigurationType.launchDjango, djangoProvider);
41+
this.providers.set(DebugConfigurationType.launchFastAPI, fastapiProvider);
3842
this.providers.set(DebugConfigurationType.launchFlask, flaskProvider);
3943
this.providers.set(DebugConfigurationType.launchFile, fileProvider);
4044
this.providers.set(DebugConfigurationType.launchModule, moduleProvider);

src/client/debugger/extension/configuration/resolvers/base.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,10 @@ export abstract class BaseConfigurationResolver<T extends DebugConfiguration>
180180
return pathMappings;
181181
}
182182

183+
protected isDebuggingFastAPI(debugConfiguration: Partial<LaunchRequestArguments & AttachRequestArguments>) {
184+
return debugConfiguration.module && debugConfiguration.module.toUpperCase() === 'FASTAPI' ? true : false;
185+
}
186+
183187
protected isDebuggingFlask(debugConfiguration: Partial<LaunchRequestArguments & AttachRequestArguments>) {
184188
return debugConfiguration.module && debugConfiguration.module.toUpperCase() === 'FLASK' ? true : false;
185189
}
@@ -195,6 +199,7 @@ export abstract class BaseConfigurationResolver<T extends DebugConfiguration>
195199
console: debugConfiguration.console,
196200
hasEnvVars: typeof debugConfiguration.env === 'object' && Object.keys(debugConfiguration.env).length > 0,
197201
django: !!debugConfiguration.django,
202+
fastapi: this.isDebuggingFastAPI(debugConfiguration),
198203
flask: this.isDebuggingFlask(debugConfiguration),
199204
hasArgs: Array.isArray(debugConfiguration.args) && debugConfiguration.args.length > 0,
200205
isLocalhost: this.isLocalHost(debugConfiguration.host),

src/client/debugger/extension/configuration/resolvers/launch.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,9 +154,10 @@ export class LaunchConfigurationResolver extends BaseConfigurationResolver<Launc
154154
if (this.platformService.isWindows) {
155155
this.debugOption(debugOptions, DebugOptions.FixFilePathCase);
156156
}
157+
const isFastAPI = this.isDebuggingFastAPI(debugConfiguration);
157158
const isFlask = this.isDebuggingFlask(debugConfiguration);
158159
if (
159-
(debugConfiguration.pyramid || isFlask) &&
160+
(debugConfiguration.pyramid || isFlask || isFastAPI) &&
160161
debugOptions.indexOf(DebugOptions.Jinja) === -1 &&
161162
debugConfiguration.jinja !== false
162163
) {

src/client/debugger/extension/serviceRegistry.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { LaunchJsonCompletionProvider } from './configuration/launch.json/comple
1818
import { InterpreterPathCommand } from './configuration/launch.json/interpreterPathCommand';
1919
import { LaunchJsonUpdaterService } from './configuration/launch.json/updaterService';
2020
import { DjangoLaunchDebugConfigurationProvider } from './configuration/providers/djangoLaunch';
21+
import { FastAPILaunchDebugConfigurationProvider } from './configuration/providers/fastapiLaunch';
2122
import { FileLaunchDebugConfigurationProvider } from './configuration/providers/fileLaunch';
2223
import { FlaskLaunchDebugConfigurationProvider } from './configuration/providers/flaskLaunch';
2324
import { ModuleLaunchDebugConfigurationProvider } from './configuration/providers/moduleLaunch';
@@ -86,6 +87,11 @@ export function registerTypes(serviceManager: IServiceManager) {
8687
DjangoLaunchDebugConfigurationProvider,
8788
DebugConfigurationType.launchDjango
8889
);
90+
serviceManager.addSingleton<IDebugConfigurationProvider>(
91+
IDebugConfigurationProvider,
92+
FastAPILaunchDebugConfigurationProvider,
93+
DebugConfigurationType.launchFastAPI
94+
);
8995
serviceManager.addSingleton<IDebugConfigurationProvider>(
9096
IDebugConfigurationProvider,
9197
FlaskLaunchDebugConfigurationProvider,

src/client/debugger/extension/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export enum DebugConfigurationType {
4040
launchFile = 'launchFile',
4141
remoteAttach = 'remoteAttach',
4242
launchDjango = 'launchDjango',
43+
launchFastAPI = 'launchFastAPI',
4344
launchFlask = 'launchFlask',
4445
launchModule = 'launchModule',
4546
launchPyramid = 'launchPyramid',

src/client/telemetry/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,12 @@ export interface IEventNamePropertyMapping {
448448
* @type {boolean}
449449
*/
450450
django: boolean;
451+
/**
452+
* Whether the user is debugging `fastapi`.
453+
*
454+
* @type {boolean}
455+
*/
456+
fastapi: boolean;
451457
/**
452458
* Whether the user is debugging `flask`.
453459
*
@@ -557,6 +563,12 @@ export interface IEventNamePropertyMapping {
557563
* @type {boolean}
558564
*/
559565
autoDetectedPyramidIniPath?: boolean;
566+
/**
567+
* Carries `true` if we are able to auto-detect main.py path for FastAPI, `false` otherwise
568+
*
569+
* @type {boolean}
570+
*/
571+
autoDetectedFastAPIMainPyPath?: boolean;
560572
/**
561573
* Carries `true` if we are able to auto-detect app.py path for Flask, `false` otherwise
562574
*
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
'use strict';
5+
6+
import { expect } from 'chai';
7+
import * as path from 'path';
8+
import { anything, instance, mock, when } from 'ts-mockito';
9+
import { Uri, WorkspaceFolder } from 'vscode';
10+
import { FileSystem } from '../../../../../client/common/platform/fileSystem';
11+
import { IFileSystem } from '../../../../../client/common/platform/types';
12+
import { DebugConfigStrings } from '../../../../../client/common/utils/localize';
13+
import { MultiStepInput } from '../../../../../client/common/utils/multiStepInput';
14+
import { DebuggerTypeName } from '../../../../../client/debugger/constants';
15+
import { FastAPILaunchDebugConfigurationProvider } from '../../../../../client/debugger/extension/configuration/providers/fastapiLaunch';
16+
import { DebugConfigurationState } from '../../../../../client/debugger/extension/types';
17+
18+
suite('Debugging - Configuration Provider FastAPI', () => {
19+
let fs: IFileSystem;
20+
let provider: TestFastAPILaunchDebugConfigurationProvider;
21+
let input: MultiStepInput<DebugConfigurationState>;
22+
class TestFastAPILaunchDebugConfigurationProvider extends FastAPILaunchDebugConfigurationProvider {
23+
// tslint:disable-next-line:no-unnecessary-override
24+
public async getApplicationPath(folder: WorkspaceFolder): Promise<string | undefined> {
25+
return super.getApplicationPath(folder);
26+
}
27+
}
28+
setup(() => {
29+
fs = mock(FileSystem);
30+
input = mock<MultiStepInput<DebugConfigurationState>>(MultiStepInput);
31+
provider = new TestFastAPILaunchDebugConfigurationProvider(instance(fs));
32+
});
33+
test("getApplicationPath should return undefined if file doesn't exist", async () => {
34+
const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 };
35+
const appPyPath = path.join(folder.uri.fsPath, 'main.py');
36+
when(fs.fileExists(appPyPath)).thenResolve(false);
37+
38+
const file = await provider.getApplicationPath(folder);
39+
40+
expect(file).to.be.equal(undefined, 'Should return undefined');
41+
});
42+
test('getApplicationPath should find path', async () => {
43+
const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 };
44+
const appPyPath = path.join(folder.uri.fsPath, 'main.py');
45+
46+
when(fs.fileExists(appPyPath)).thenResolve(true);
47+
48+
const file = await provider.getApplicationPath(folder);
49+
50+
expect(file).to.be.equal('main.py');
51+
});
52+
test('Launch JSON with valid python path', async () => {
53+
const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 };
54+
const state = { config: {}, folder };
55+
provider.getApplicationPath = () => Promise.resolve('xyz.py');
56+
57+
await provider.buildConfiguration(instance(input), state);
58+
59+
const config = {
60+
name: DebugConfigStrings.fastapi.snippet.name(),
61+
type: DebuggerTypeName,
62+
request: 'launch',
63+
module: 'uvicorn',
64+
args: ['main:app'],
65+
jinja: true
66+
};
67+
68+
expect(state.config).to.be.deep.equal(config);
69+
});
70+
test('Launch JSON with selected app path', async () => {
71+
const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 };
72+
const state = { config: {}, folder };
73+
provider.getApplicationPath = () => Promise.resolve(undefined);
74+
75+
when(input.showInputBox(anything())).thenResolve('main');
76+
77+
await provider.buildConfiguration(instance(input), state);
78+
79+
const config = {
80+
name: DebugConfigStrings.fastapi.snippet.name(),
81+
type: DebuggerTypeName,
82+
request: 'launch',
83+
module: 'uvicorn',
84+
args: ['main:app'],
85+
jinja: true
86+
};
87+
88+
expect(state.config).to.be.deep.equal(config);
89+
});
90+
});

src/test/debugger/extension/configuration/providers/providerFactory.unit.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ suite('Debugging - Configuration Provider Factory', () => {
2020
mappedProviders.set(item.value, (item.value as any) as IDebugConfigurationProvider);
2121
});
2222
factory = new DebugConfigurationProviderFactory(
23+
mappedProviders.get(DebugConfigurationType.launchFastAPI)!,
2324
mappedProviders.get(DebugConfigurationType.launchFlask)!,
2425
mappedProviders.get(DebugConfigurationType.launchDjango)!,
2526
mappedProviders.get(DebugConfigurationType.launchModule)!,

0 commit comments

Comments
 (0)