Skip to content

Commit e1dca79

Browse files
Support the Black formatter (#1611)
Co-authored-by: Josh Smeaton <[email protected]>
1 parent f1e1693 commit e1dca79

File tree

16 files changed

+228
-72
lines changed

16 files changed

+228
-72
lines changed

news/1 Enhancements/1153.md

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Add support for the [Black formatter](https://pypi.org/project/black/)
2+
(thanks to [Josh Smeaton](https://github.com/jarshwah) for the initial patch)

package.json

+17-1
Original file line numberDiff line numberDiff line change
@@ -1200,14 +1200,30 @@
12001200
"python.formatting.provider": {
12011201
"type": "string",
12021202
"default": "autopep8",
1203-
"description": "Provider for formatting. Possible options include 'autopep8' and 'yapf'.",
1203+
"description": "Provider for formatting. Possible options include 'autopep8', 'black', and 'yapf'.",
12041204
"enum": [
12051205
"autopep8",
1206+
"black",
12061207
"yapf",
12071208
"none"
12081209
],
12091210
"scope": "resource"
12101211
},
1212+
"python.formatting.blackArgs": {
1213+
"type": "array",
1214+
"description": "Arguments passed in. Each argument is a separate item in the array.",
1215+
"default": [],
1216+
"items": {
1217+
"type": "string"
1218+
},
1219+
"scope": "resource"
1220+
},
1221+
"python.formatting.blackPath": {
1222+
"type": "string",
1223+
"default": "black",
1224+
"description": "Path to Black, you can use a custom version of Black by modifying this setting to include the full path.",
1225+
"scope": "resource"
1226+
},
12111227
"python.formatting.yapfArgs": {
12121228
"type": "array",
12131229
"description": "Arguments passed in. Each argument is a separate item in the array.",

requirements.txt

+1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
# but flake8 has a tighter pinning.
33
flake8
44
autopep8
5+
black ; python_version>='3.6'
56
yapf
67
pylint
78
pep8

src/client/common/configSettings.ts

+4-3
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,12 @@ export class PythonSettings extends EventEmitter implements IPythonSettings {
3434
public venvFolders: string[] = [];
3535
public devOptions: string[] = [];
3636
public linting?: ILintingSettings;
37-
public formatting?: IFormattingSettings;
37+
public formatting!: IFormattingSettings;
3838
public autoComplete?: IAutoCompleteSettings;
39-
public unitTest?: IUnitTestSettings;
39+
public unitTest!: IUnitTestSettings;
4040
public terminal!: ITerminalSettings;
4141
public sortImports?: ISortImportSettings;
42-
public workspaceSymbols?: IWorkspaceSymbolSettings;
42+
public workspaceSymbols!: IWorkspaceSymbolSettings;
4343
public disableInstallationChecks = false;
4444
public globalModuleInstallation = false;
4545

@@ -213,6 +213,7 @@ export class PythonSettings extends EventEmitter implements IPythonSettings {
213213
this.formatting = this.formatting ? this.formatting : {
214214
autopep8Args: [], autopep8Path: 'autopep8',
215215
provider: 'autopep8',
216+
blackArgs: [], blackPath: 'black',
216217
yapfArgs: [], yapfPath: 'yapf'
217218
};
218219
this.formatting.autopep8Path = getAbsolutePath(systemVariables.resolveAny(this.formatting.autopep8Path), workspaceRoot);

src/client/common/installer/productInstaller.ts

+41-31
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { inject, injectable, named } from 'inversify';
22
import * as os from 'os';
33
import * as path from 'path';
4-
import { OutputChannel, Uri } from 'vscode';
54
import * as vscode from 'vscode';
65
import { IFormatterHelper } from '../../formatters/types';
76
import { IServiceContainer } from '../../ioc/types';
@@ -33,14 +32,14 @@ abstract class BaseInstaller {
3332
protected appShell: IApplicationShell;
3433
protected configService: IConfigurationService;
3534

36-
constructor(protected serviceContainer: IServiceContainer, protected outputChannel: OutputChannel) {
35+
constructor(protected serviceContainer: IServiceContainer, protected outputChannel: vscode.OutputChannel) {
3736
this.appShell = serviceContainer.get<IApplicationShell>(IApplicationShell);
3837
this.configService = serviceContainer.get<IConfigurationService>(IConfigurationService);
3938
}
4039

41-
public abstract promptToInstall(product: Product, resource?: Uri): Promise<InstallerResponse>;
40+
public abstract promptToInstall(product: Product, resource?: vscode.Uri): Promise<InstallerResponse>;
4241

43-
public async install(product: Product, resource?: Uri): Promise<InstallerResponse> {
42+
public async install(product: Product, resource?: vscode.Uri): Promise<InstallerResponse> {
4443
if (product === Product.unittest) {
4544
return InstallerResponse.Installed;
4645
}
@@ -60,7 +59,7 @@ abstract class BaseInstaller {
6059
.then(isInstalled => isInstalled ? InstallerResponse.Installed : InstallerResponse.Ignore);
6160
}
6261

63-
public async isInstalled(product: Product, resource?: Uri): Promise<boolean | undefined> {
62+
public async isInstalled(product: Product, resource?: vscode.Uri): Promise<boolean | undefined> {
6463
if (product === Product.unittest) {
6564
return true;
6665
}
@@ -85,22 +84,22 @@ abstract class BaseInstaller {
8584
}
8685
}
8786

88-
protected getExecutableNameFromSettings(product: Product, resource?: Uri): string {
87+
protected getExecutableNameFromSettings(product: Product, resource?: vscode.Uri): string {
8988
throw new Error('getExecutableNameFromSettings is not supported on this object');
9089
}
9190
}
9291

9392
class CTagsInstaller extends BaseInstaller {
94-
constructor(serviceContainer: IServiceContainer, outputChannel: OutputChannel) {
93+
constructor(serviceContainer: IServiceContainer, outputChannel: vscode.OutputChannel) {
9594
super(serviceContainer, outputChannel);
9695
}
9796

98-
public async promptToInstall(product: Product, resource?: Uri): Promise<InstallerResponse> {
97+
public async promptToInstall(product: Product, resource?: vscode.Uri): Promise<InstallerResponse> {
9998
const item = await this.appShell.showErrorMessage('Install CTags to enable Python workspace symbols?', 'Yes', 'No');
10099
return item === 'Yes' ? this.install(product, resource) : InstallerResponse.Ignore;
101100
}
102101

103-
public async install(product: Product, resource?: Uri): Promise<InstallerResponse> {
102+
public async install(product: Product, resource?: vscode.Uri): Promise<InstallerResponse> {
104103
if (this.serviceContainer.get<IPlatformService>(IPlatformService).isWindows) {
105104
this.outputChannel.appendLine('Install Universal Ctags Win32 to enable support for Workspace Symbols');
106105
this.outputChannel.appendLine('Download the CTags binary from the Universal CTags site.');
@@ -117,32 +116,41 @@ class CTagsInstaller extends BaseInstaller {
117116
return InstallerResponse.Ignore;
118117
}
119118

120-
protected getExecutableNameFromSettings(product: Product, resource?: Uri): string {
119+
protected getExecutableNameFromSettings(product: Product, resource?: vscode.Uri): string {
121120
const settings = this.configService.getSettings(resource);
122121
return settings.workspaceSymbols.ctagsPath;
123122
}
124123
}
125124

126125
class FormatterInstaller extends BaseInstaller {
127-
public async promptToInstall(product: Product, resource?: Uri): Promise<InstallerResponse> {
126+
public async promptToInstall(product: Product, resource?: vscode.Uri): Promise<InstallerResponse> {
127+
// Hard-coded on purpose because the UI won't necessarily work having
128+
// another formatter.
129+
const formatters = [Product.autopep8, Product.black, Product.yapf];
130+
const formatterNames = formatters.map((formatter) => ProductNames.get(formatter)!);
128131
const productName = ProductNames.get(product)!;
132+
formatterNames.splice(formatterNames.indexOf(productName), 1);
133+
const useOptions = formatterNames.map((name) => `Use ${name}`);
134+
const yesChoice = 'Yes';
129135

130-
const installThis = `Install ${productName}`;
131-
const alternateFormatter = product === Product.autopep8 ? 'yapf' : 'autopep8';
132-
const useOtherFormatter = `Use '${alternateFormatter}' formatter`;
133-
const item = await this.appShell.showErrorMessage(`Formatter ${productName} is not installed.`, installThis, useOtherFormatter);
134-
135-
if (item === installThis) {
136+
const item = await this.appShell.showErrorMessage(`Formatter ${productName} is not installed. Install?`, yesChoice, ...useOptions);
137+
if (item === yesChoice) {
136138
return this.install(product, resource);
139+
} else if (typeof item === 'string') {
140+
for (const formatter of formatters) {
141+
const formatterName = ProductNames.get(formatter)!;
142+
143+
if (item.endsWith(formatterName)) {
144+
await this.configService.updateSettingAsync('formatting.provider', formatterName, resource);
145+
return this.install(formatter, resource);
146+
}
147+
}
137148
}
138-
if (item === useOtherFormatter) {
139-
await this.configService.updateSettingAsync('formatting.provider', alternateFormatter, resource);
140-
return InstallerResponse.Installed;
141-
}
149+
142150
return InstallerResponse.Ignore;
143151
}
144152

145-
protected getExecutableNameFromSettings(product: Product, resource?: Uri): string {
153+
protected getExecutableNameFromSettings(product: Product, resource?: vscode.Uri): string {
146154
const settings = this.configService.getSettings(resource);
147155
const formatHelper = this.serviceContainer.get<IFormatterHelper>(IFormatterHelper);
148156
const settingsPropNames = formatHelper.getSettingsPropertyNames(product);
@@ -152,7 +160,7 @@ class FormatterInstaller extends BaseInstaller {
152160

153161
// tslint:disable-next-line:max-classes-per-file
154162
class LinterInstaller extends BaseInstaller {
155-
public async promptToInstall(product: Product, resource?: Uri): Promise<InstallerResponse> {
163+
public async promptToInstall(product: Product, resource?: vscode.Uri): Promise<InstallerResponse> {
156164
const productName = ProductNames.get(product)!;
157165
const install = 'Install';
158166
const disableAllLinting = 'Disable linting';
@@ -173,21 +181,21 @@ class LinterInstaller extends BaseInstaller {
173181
}
174182
return InstallerResponse.Ignore;
175183
}
176-
protected getExecutableNameFromSettings(product: Product, resource?: Uri): string {
184+
protected getExecutableNameFromSettings(product: Product, resource?: vscode.Uri): string {
177185
const linterManager = this.serviceContainer.get<ILinterManager>(ILinterManager);
178186
return linterManager.getLinterInfo(product).pathName(resource);
179187
}
180188
}
181189

182190
// tslint:disable-next-line:max-classes-per-file
183191
class TestFrameworkInstaller extends BaseInstaller {
184-
public async promptToInstall(product: Product, resource?: Uri): Promise<InstallerResponse> {
192+
public async promptToInstall(product: Product, resource?: vscode.Uri): Promise<InstallerResponse> {
185193
const productName = ProductNames.get(product)!;
186194
const item = await this.appShell.showErrorMessage(`Test framework ${productName} is not installed. Install?`, 'Yes', 'No');
187195
return item === 'Yes' ? this.install(product, resource) : InstallerResponse.Ignore;
188196
}
189197

190-
protected getExecutableNameFromSettings(product: Product, resource?: Uri): string {
198+
protected getExecutableNameFromSettings(product: Product, resource?: vscode.Uri): string {
191199
const testHelper = this.serviceContainer.get<ITestsHelper>(ITestsHelper);
192200
const settingsPropNames = testHelper.getSettingsPropertyNames(product);
193201
if (!settingsPropNames.pathName) {
@@ -201,12 +209,12 @@ class TestFrameworkInstaller extends BaseInstaller {
201209

202210
// tslint:disable-next-line:max-classes-per-file
203211
class RefactoringLibraryInstaller extends BaseInstaller {
204-
public async promptToInstall(product: Product, resource?: Uri): Promise<InstallerResponse> {
212+
public async promptToInstall(product: Product, resource?: vscode.Uri): Promise<InstallerResponse> {
205213
const productName = ProductNames.get(product)!;
206214
const item = await this.appShell.showErrorMessage(`Refactoring library ${productName} is not installed. Install?`, 'Yes', 'No');
207215
return item === 'Yes' ? this.install(product, resource) : InstallerResponse.Ignore;
208216
}
209-
protected getExecutableNameFromSettings(product: Product, resource?: Uri): string {
217+
protected getExecutableNameFromSettings(product: Product, resource?: vscode.Uri): string {
210218
return translateProductToModule(product, ModuleNamePurpose.run);
211219
}
212220
}
@@ -230,19 +238,20 @@ export class ProductInstaller implements IInstaller {
230238
this.ProductTypes.set(Product.pytest, ProductType.TestFramework);
231239
this.ProductTypes.set(Product.unittest, ProductType.TestFramework);
232240
this.ProductTypes.set(Product.autopep8, ProductType.Formatter);
241+
this.ProductTypes.set(Product.black, ProductType.Formatter);
233242
this.ProductTypes.set(Product.yapf, ProductType.Formatter);
234243
this.ProductTypes.set(Product.rope, ProductType.RefactoringLibrary);
235244
}
236245

237246
// tslint:disable-next-line:no-empty
238247
public dispose() { }
239-
public async promptToInstall(product: Product, resource?: Uri): Promise<InstallerResponse> {
248+
public async promptToInstall(product: Product, resource?: vscode.Uri): Promise<InstallerResponse> {
240249
return this.createInstaller(product).promptToInstall(product, resource);
241250
}
242-
public async install(product: Product, resource?: Uri): Promise<InstallerResponse> {
251+
public async install(product: Product, resource?: vscode.Uri): Promise<InstallerResponse> {
243252
return this.createInstaller(product).install(product, resource);
244253
}
245-
public async isInstalled(product: Product, resource?: Uri): Promise<boolean | undefined> {
254+
public async isInstalled(product: Product, resource?: vscode.Uri): Promise<boolean | undefined> {
246255
return this.createInstaller(product).isInstalled(product, resource);
247256
}
248257
public translateProductToModuleName(product: Product, purpose: ModuleNamePurpose): string {
@@ -280,6 +289,7 @@ function translateProductToModule(product: Product, purpose: ModuleNamePurpose):
280289
case Product.pylint: return 'pylint';
281290
case Product.pytest: return 'pytest';
282291
case Product.autopep8: return 'autopep8';
292+
case Product.black: return 'black';
283293
case Product.pep8: return 'pep8';
284294
case Product.pydocstyle: return 'pydocstyle';
285295
case Product.yapf: return 'yapf';

src/client/common/installer/productNames.ts

+1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Product } from '../types';
66
// tslint:disable-next-line:variable-name
77
export const ProductNames = new Map<Product, string>();
88
ProductNames.set(Product.autopep8, 'autopep8');
9+
ProductNames.set(Product.black, 'black');
910
ProductNames.set(Product.flake8, 'flake8');
1011
ProductNames.set(Product.mypy, 'mypy');
1112
ProductNames.set(Product.nosetest, 'nosetest');

src/client/common/types.ts

+7-4
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,8 @@ export enum Product {
6262
unittest = 12,
6363
ctags = 13,
6464
rope = 14,
65-
isort = 15
65+
isort = 15,
66+
black = 16
6667
}
6768

6869
export enum ModuleNamePurpose {
@@ -103,12 +104,12 @@ export interface IPythonSettings {
103104
readonly jediMemoryLimit: number;
104105
readonly devOptions: string[];
105106
readonly linting?: ILintingSettings;
106-
readonly formatting?: IFormattingSettings;
107-
readonly unitTest?: IUnitTestSettings;
107+
readonly formatting: IFormattingSettings;
108+
readonly unitTest: IUnitTestSettings;
108109
readonly autoComplete?: IAutoCompleteSettings;
109110
readonly terminal: ITerminalSettings;
110111
readonly sortImports?: ISortImportSettings;
111-
readonly workspaceSymbols?: IWorkspaceSymbolSettings;
112+
readonly workspaceSymbols: IWorkspaceSymbolSettings;
112113
readonly envFile: string;
113114
readonly disablePromptForFeatures: string[];
114115
readonly disableInstallationChecks: boolean;
@@ -191,6 +192,8 @@ export interface IFormattingSettings {
191192
readonly provider: string;
192193
autopep8Path: string;
193194
readonly autopep8Args: string[];
195+
blackPath: string;
196+
readonly blackArgs: string[];
194197
yapfPath: string;
195198
readonly yapfArgs: string[];
196199
}

src/client/formatters/baseFormatter.ts

+9-10
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import * as fs from 'fs-extra';
22
import * as path from 'path';
33
import * as vscode from 'vscode';
4-
import { OutputChannel, TextEdit, Uri } from 'vscode';
54
import { IWorkspaceService } from '../common/application/types';
65
import { STANDARD_OUTPUT_CHANNEL } from '../common/constants';
76
import '../common/extensions';
@@ -13,12 +12,12 @@ import { getTempFileWithDocumentContents, getTextEditsFromPatch } from './../com
1312
import { IFormatterHelper } from './types';
1413

1514
export abstract class BaseFormatter {
16-
protected readonly outputChannel: OutputChannel;
15+
protected readonly outputChannel: vscode.OutputChannel;
1716
protected readonly workspace: IWorkspaceService;
1817
private readonly helper: IFormatterHelper;
1918

2019
constructor(public Id: string, private product: Product, protected serviceContainer: IServiceContainer) {
21-
this.outputChannel = serviceContainer.get<OutputChannel>(IOutputChannel, STANDARD_OUTPUT_CHANNEL);
20+
this.outputChannel = serviceContainer.get<vscode.OutputChannel>(IOutputChannel, STANDARD_OUTPUT_CHANNEL);
2221
this.helper = serviceContainer.get<IFormatterHelper>(IFormatterHelper);
2322
this.workspace = serviceContainer.get<IWorkspaceService>(IWorkspaceService);
2423
}
@@ -49,8 +48,8 @@ export abstract class BaseFormatter {
4948

5049
// autopep8 and yapf have the ability to read from the process input stream and return the formatted code out of the output stream.
5150
// However they don't support returning the diff of the formatted text when reading data from the input stream.
52-
// Yes getting text formatted that way avoids having to create a temporary file, however the diffing will have
53-
// to be done here in node (extension), i.e. extension cpu, i.e. les responsive solution.
51+
// Yet getting text formatted that way avoids having to create a temporary file, however the diffing will have
52+
// to be done here in node (extension), i.e. extension CPU, i.e. less responsive solution.
5453
const tempFile = await this.createTempFile(document);
5554
if (this.checkCancellation(document.fileName, tempFile, token)) {
5655
return [];
@@ -63,17 +62,17 @@ export abstract class BaseFormatter {
6362
.then(output => output.stdout)
6463
.then(data => {
6564
if (this.checkCancellation(document.fileName, tempFile, token)) {
66-
return [] as TextEdit[];
65+
return [] as vscode.TextEdit[];
6766
}
6867
return getTextEditsFromPatch(document.getText(), data);
6968
})
7069
.catch(error => {
7170
if (this.checkCancellation(document.fileName, tempFile, token)) {
72-
return [] as TextEdit[];
71+
return [] as vscode.TextEdit[];
7372
}
7473
// tslint:disable-next-line:no-empty
7574
this.handleError(this.Id, error, document.uri).catch(() => { });
76-
return [] as TextEdit[];
75+
return [] as vscode.TextEdit[];
7776
})
7877
.then(edits => {
7978
this.deleteTempFile(document.fileName, tempFile).ignoreErrors();
@@ -83,7 +82,7 @@ export abstract class BaseFormatter {
8382
return promise;
8483
}
8584

86-
protected async handleError(expectedFileName: string, error: Error, resource?: Uri) {
85+
protected async handleError(expectedFileName: string, error: Error, resource?: vscode.Uri) {
8786
let customError = `Formatting with ${this.Id} failed.`;
8887

8988
if (isNotInstalledError(error)) {
@@ -100,7 +99,7 @@ export abstract class BaseFormatter {
10099

101100
private async createTempFile(document: vscode.TextDocument): Promise<string> {
102101
return document.isDirty
103-
? await getTempFileWithDocumentContents(document)
102+
? getTempFileWithDocumentContents(document)
104103
: document.fileName;
105104
}
106105

0 commit comments

Comments
 (0)