diff --git a/python/pluginResources/messages/PyBundle.properties b/python/pluginResources/messages/PyBundle.properties
index 02e13015f1b0..11171288d8ea 100644
--- a/python/pluginResources/messages/PyBundle.properties
+++ b/python/pluginResources/messages/PyBundle.properties
@@ -1373,4 +1373,50 @@ python.sdk.configurable.name=Python SDK
inlay.hints.usages.text={0,choice, 0#no usages|1#1 usage|2#{0,number}'{1,choice, |1#+}' usages}
inlay.hints.usages.with.dynamic.text={0,choice, 0#no usages|1#1 usage|2#{0,number} usages} ({1,choice, 1#1 dynamic|2#{1,number} dynamic})
-
+# Black formatter
+black.configurable.name=Black
+black.formatting.service.name=Black formatter
+black.not.installed.error=Black formatter package is not installed on the current interpreter
+black.no.project.interpreter.error=No Python interpreter configured for the project
+black.remote.sdk.error=Package mode is not available for remote SDKs. Please use Binary mode.
+black.install.button.label=Install Black
+black.use.section.label=Use Black formatter:
+black.enable.black.checkbox.label=On code reformat
+black.enable.action.on.save.label=On save
+black.advanced.settings.panel.title=Advanced settings
+black.empty.output.error=Black formatter returned empty output
+black.empty.path.to.executable.exception.text=Black executable not found or empty
+black.action.on.save.name=Run Black
+black.action.on.save.package.info=Using Black package v{0}
+black.action.on.save.executable.info=Using Black executable v{0}
+black.action.on.save.executable.path.not.specified=Black executable path not specified
+black.formatting.with.black=Formatting with Black
+black.no.lines.changed=Black: No lines changed. Content is already properly formatted.
+black.formatted.n.lines=Black: Formatted {0} {1, choice, 0#lines|1#line}
+black.installing.modal.title=Installing Black formatter
+black.installation.error.title=Failed to install Black formatter
+black.failed.to.format.on.save.error.label=Black: Failed to format file {0}
+black.processing.file.name=Black: Processing file {0}
+black.exception.error.message=Black formatter error
+black.file.ignored.notification.label=Black: File ignored
+black.file.ignored.notification.message=File {0} ignored according to --exclude or --force-exclude rules
+black.advertising.service.notification.title=Black formatter integration
+black.advertising.service.found.in.packages=Black formatter package is detected on the project interpreter. Try Black formatter integration!
+black.advertising.service.found.in.PATH=Black formatter executable is detected in {0, choice, 0#%PATH%|1#$PATH}. Try Black formatter integration!
+black.advertising.service.configure.button.label=Configure
+black.advertising.service.dont.show.again.label=Don't show again
+black.select.path.to.executable=Select path to Black formatter executable
+black.execution.mode.label=Execution mode:
+black.executable.label=Black executable:
+black.executable.auto.detected.path=Auto-detected: {0}
+black.executable.not.found=Unable to auto-detect Black executable in {0, choice, 0#%PATH%|1#$PATH}
+black.execution.mode.package=Package
+black.execution.mode.binary=Binary
+black.execution.mode.tooltip.text=Use either executable or package installed on the project SDK
+black.cli.args.text.field.label=Settings:
+black.cli.args.comment=List command line arguments separated by whitespace. Learn more
+black.cli.args.validation.notification=No value passed for parameter {0}
+black.sdk.not.configured.error=No project SDK configured for the project {0}
+black.sdk.not.configured.error.title=SDK not configured
+black.remote.sdk.exception.text=Black formatter invocation in Package mode is not allowed on remote SDKs
+black.sdk.selection.combobox.label=Select Python SDK:
diff --git a/python/setup-test-environment/build.gradle b/python/setup-test-environment/build.gradle
index 314c5ab4feb2..725f793c13b9 100644
--- a/python/setup-test-environment/build.gradle
+++ b/python/setup-test-environment/build.gradle
@@ -154,8 +154,8 @@ envs {
createPython("python3.11",
python311version,
- [],
- "python3.11",
+ ["black == 23.1.0"],
+ "python3.11\nblack",
true)
createPython("python3.12",
diff --git a/python/src/META-INF/python-core-common.xml b/python/src/META-INF/python-core-common.xml
index f87112beea82..4ee097090e31 100644
--- a/python/src/META-INF/python-core-common.xml
+++ b/python/src/META-INF/python-core-common.xml
@@ -512,6 +512,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/python/src/com/jetbrains/python/black/BlackFormatterActionOnSave.kt b/python/src/com/jetbrains/python/black/BlackFormatterActionOnSave.kt
new file mode 100644
index 000000000000..12c1b37c5438
--- /dev/null
+++ b/python/src/com/jetbrains/python/black/BlackFormatterActionOnSave.kt
@@ -0,0 +1,127 @@
+// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+package com.jetbrains.python.black
+
+import com.intellij.ide.actionsOnSave.impl.ActionsOnSaveFileDocumentManagerListener.ActionOnSave
+import com.intellij.notification.Notification
+import com.intellij.notification.NotificationType
+import com.intellij.notification.Notifications
+import com.intellij.openapi.application.EDT
+import com.intellij.openapi.command.WriteCommandAction
+import com.intellij.openapi.diagnostic.thisLogger
+import com.intellij.openapi.editor.Document
+import com.intellij.openapi.fileEditor.FileDocumentManager
+import com.intellij.openapi.progress.progressStep
+import com.intellij.openapi.progress.runBlockingModal
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.projectRoots.Sdk
+import com.intellij.openapi.util.registry.Registry
+import com.intellij.openapi.vfs.VirtualFile
+import com.jetbrains.python.PyBundle
+import com.jetbrains.python.black.configuration.BlackFormatterConfiguration
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import org.jetbrains.annotations.Nls
+import kotlin.coroutines.cancellation.CancellationException
+
+class BlackFormatterActionOnSave : ActionOnSave() {
+
+ companion object {
+ val LOG = thisLogger()
+ }
+
+ override fun isEnabledForProject(project: Project): Boolean = Registry.`is`("black.formatter.support.enabled")
+
+ override fun processDocuments(project: Project, documents: Array) {
+ val blackConfig = BlackFormatterConfiguration.getBlackConfiguration(project)
+ if (!blackConfig.enabledOnSave) return
+
+ val sdk = blackConfig.getSdk(project)
+ if (sdk == null) {
+ LOG.warn(PyBundle.message("black.sdk.not.configured.error", project.name))
+ return
+ }
+
+ formatMultipleDocuments(project, sdk, blackConfig, documents.filterNotNull().toList())
+ }
+
+ private fun formatMultipleDocuments(project: Project,
+ sdk: Sdk,
+ blackConfig: BlackFormatterConfiguration,
+ documents: List) {
+ val manager = FileDocumentManager.getInstance()
+
+ val executor = try {
+ BlackFormatterExecutor(project, sdk, blackConfig)
+ }
+ catch (e: Exception) {
+ reportFailure(PyBundle.message("black.exception.error.message"), e.localizedMessage, project)
+ return
+ }
+
+ val descriptors = documents
+ .mapNotNull { document -> manager.getFile(document)?.let { document to it } }
+ .filter { BlackFormatterUtil.isFileApplicable(it.second) }
+ .map { Descriptor(it.first, it.second) }
+
+ runCatching {
+ runBlockingModal(project, PyBundle.message("black.formatting.with.black")) {
+ var processedFiles = 0L
+
+ descriptors.forEach { descriptor ->
+ processedFiles++
+ progressStep(processedFiles / descriptors.size.toDouble(),
+ PyBundle.message("black.processing.file.name", descriptor.virtualFile.name)) {
+ val request = BlackFormattingRequest.File(descriptor.document.text, descriptor.virtualFile)
+ val response = executor.getBlackFormattingResponse(request, BlackFormatterExecutor.BLACK_DEFAULT_TIMEOUT)
+ applyChanges(project, descriptor, response)
+ }
+ }
+ }
+ }.onFailure { exception ->
+ when (exception) {
+ is CancellationException -> { /* ignore */ }
+ else -> {
+ LOG.warn(exception)
+ reportFailure(PyBundle.message("black.exception.error.message"), exception.localizedMessage, project)
+ }
+ }
+ }
+ }
+
+ private suspend fun applyChanges(project: Project, descriptor: Descriptor, response: BlackFormattingResponse) {
+ when (response) {
+ is BlackFormattingResponse.Success -> {
+ withContext(Dispatchers.EDT) {
+ WriteCommandAction
+ .runWriteCommandAction(project,
+ null,
+ null,
+ { descriptor.document.setText(response.formattedText) })
+ }
+ }
+ is BlackFormattingResponse.Failure -> {
+ reportFailure(response.title, response.description, project)
+ }
+ is BlackFormattingResponse.Ignored -> {
+ reportIgnored(response.title, response.description, project)
+ }
+ }
+ }
+
+ private fun reportFailure(@Nls title: String, @Nls message: String, project: Project) {
+ Notifications.Bus.notify(
+ Notification(BlackFormattingService.NOTIFICATION_GROUP_ID,
+ title,
+ message, NotificationType.ERROR), project)
+ }
+
+ // [TODO] add `do not show again` option
+ private fun reportIgnored(@Nls title: String, @Nls message: String, project: Project) {
+ Notifications.Bus.notify(
+ Notification(BlackFormattingService.NOTIFICATION_GROUP_ID,
+ title,
+ message, NotificationType.INFORMATION), project)
+ }
+
+ private data class Descriptor(val document: Document, val virtualFile: VirtualFile)
+}
\ No newline at end of file
diff --git a/python/src/com/jetbrains/python/black/BlackFormatterAdvertiserService.kt b/python/src/com/jetbrains/python/black/BlackFormatterAdvertiserService.kt
new file mode 100644
index 000000000000..53f90399f2b4
--- /dev/null
+++ b/python/src/com/jetbrains/python/black/BlackFormatterAdvertiserService.kt
@@ -0,0 +1,73 @@
+// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+package com.jetbrains.python.black
+
+import com.intellij.ide.util.PropertiesComponent
+import com.intellij.notification.NotificationAction
+import com.intellij.notification.NotificationGroupManager
+import com.intellij.notification.NotificationType
+import com.intellij.openapi.application.ApplicationManager
+import com.intellij.openapi.components.Service
+import com.intellij.openapi.options.ShowSettingsUtil
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.util.SystemInfo
+import com.jetbrains.python.PyBundle
+import com.jetbrains.python.black.configuration.BlackFormatterConfigurable
+import com.jetbrains.python.black.configuration.BlackFormatterConfiguration
+import org.jetbrains.annotations.ApiStatus
+import org.jetbrains.annotations.Nls
+import org.jetbrains.annotations.NonNls
+
+@ApiStatus.Internal
+@Service(Service.Level.APP)
+class BlackFormatterAdvertiserService private constructor() {
+
+ companion object {
+ @NonNls
+ const val SHOW_BLACK_FORMATTER_SUPPORT_NOTIFICATION = "black.formatter.show.support.notification"
+
+ fun getInstance(): BlackFormatterAdvertiserService =
+ ApplicationManager.getApplication().getService(BlackFormatterAdvertiserService::class.java)
+ }
+
+ private var alreadyShown: Boolean = false
+
+ fun suggestBlack(project: Project, blackFormatterConfiguration: BlackFormatterConfiguration) {
+ if (blackFormatterConfiguration == BlackFormatterConfiguration() && !alreadyShown) {
+ if (BlackFormatterUtil.isBlackFormatterInstalledOnProjectSdk(blackFormatterConfiguration.getSdk(project))) {
+ showBlackFormatterSupportNotification(project,
+ PyBundle.message("black.advertising.service.found.in.packages"))
+ }
+ else if (BlackFormatterUtil.isBlackExecutableDetected()) {
+ showBlackFormatterSupportNotification(project,
+ PyBundle.message("black.advertising.service.found.in.PATH",
+ if (SystemInfo.isWindows) 0 else 1))
+ }
+ }
+ }
+
+ @Synchronized
+ private fun showBlackFormatterSupportNotification(project: Project, @Nls message: String) {
+ val propertiesComponent = PropertiesComponent.getInstance()
+ if (!propertiesComponent.getBoolean(SHOW_BLACK_FORMATTER_SUPPORT_NOTIFICATION, true)) {
+ return
+ }
+
+ val notification = NotificationGroupManager.getInstance().getNotificationGroup(BlackFormattingService.NOTIFICATION_GROUP_ID)
+ .createNotification(PyBundle.message("black.advertising.service.notification.title"), message, NotificationType.INFORMATION)
+ .setDisplayId("black.formatter")
+ .setSuggestionType(true)
+ .setImportantSuggestion(true)
+ .addAction(NotificationAction
+ .createSimpleExpiring(PyBundle.message("black.advertising.service.configure.button.label")) {
+ ShowSettingsUtil.getInstance().showSettingsDialog(project, BlackFormatterConfigurable::class.java)
+ })
+ .addAction(NotificationAction
+ .createSimpleExpiring(PyBundle.message("black.advertising.service.dont.show.again.label")) {
+ propertiesComponent.setValue(
+ SHOW_BLACK_FORMATTER_SUPPORT_NOTIFICATION,
+ "false", "true")
+ })
+ notification.notify(project)
+ alreadyShown = true
+ }
+}
\ No newline at end of file
diff --git a/python/src/com/jetbrains/python/black/BlackFormatterExecutor.kt b/python/src/com/jetbrains/python/black/BlackFormatterExecutor.kt
new file mode 100644
index 000000000000..5ba1f63f09cf
--- /dev/null
+++ b/python/src/com/jetbrains/python/black/BlackFormatterExecutor.kt
@@ -0,0 +1,174 @@
+// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+package com.jetbrains.python.black
+
+import com.intellij.execution.process.*
+import com.intellij.execution.target.*
+import com.intellij.execution.target.local.LocalTargetEnvironment
+import com.intellij.execution.target.local.LocalTargetEnvironmentRequest
+import com.intellij.openapi.diagnostic.thisLogger
+import com.intellij.openapi.fileTypes.FileTypeRegistry
+import com.intellij.openapi.progress.util.ProgressIndicatorUtils
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.projectRoots.Sdk
+import com.intellij.openapi.util.Version
+import com.intellij.openapi.vfs.VirtualFile
+import com.jetbrains.python.PyBundle
+import com.jetbrains.python.black.configuration.BlackFormatterConfiguration
+import com.jetbrains.python.pyi.PyiFileType
+import com.jetbrains.python.run.PythonInterpreterTargetEnvironmentFactory
+import com.jetbrains.python.sdk.InvalidSdkException
+import com.jetbrains.python.sdk.configureBuilderToRunPythonOnTarget
+import java.io.FileNotFoundException
+import java.io.IOException
+import java.nio.charset.Charset
+import java.util.concurrent.CompletableFuture
+import java.util.concurrent.TimeUnit
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.milliseconds
+
+class BlackFormatterExecutor(private val project: Project,
+ private val sdk: Sdk,
+ private val blackConfig: BlackFormatterConfiguration) {
+
+ private var targetEnvironment: TargetEnvironment
+ private var targetEnvironmentRequest: TargetEnvironmentRequest
+
+ companion object {
+ val BLACK_DEFAULT_TIMEOUT = 30000.milliseconds
+ private val LOG = thisLogger()
+ private val minimalStdinFilenameCompatibleVersion = Version(21, 4, 0)
+ }
+
+ init {
+ when (blackConfig.executionMode) {
+ BlackFormatterConfiguration.ExecutionMode.BINARY -> {
+ targetEnvironmentRequest = LocalTargetEnvironmentRequest()
+ targetEnvironment = LocalTargetEnvironment(LocalTargetEnvironmentRequest())
+ }
+ BlackFormatterConfiguration.ExecutionMode.PACKAGE -> {
+ if (!sdk.sdkType.isLocalSdk(sdk)) {
+ throw InvalidSdkException(PyBundle.message("black.remote.sdk.exception.text"))
+ }
+ val interpreter = PythonInterpreterTargetEnvironmentFactory.findPythonTargetInterpreter(sdk, project)
+ targetEnvironmentRequest = interpreter.targetEnvironmentRequest
+ targetEnvironment = targetEnvironmentRequest.prepareEnvironment(TargetProgressIndicator.EMPTY)
+ }
+ }
+ }
+
+
+ fun getBlackFormattingResponse(blackFormattingRequest: BlackFormattingRequest,
+ timeout: Duration): BlackFormattingResponse {
+ val targetCMD = buildTargetCommandLine(blackFormattingRequest, targetEnvironmentRequest)
+ val future = getFuture(targetCMD, targetEnvironment, blackFormattingRequest, timeout)
+ return ProgressIndicatorUtils.awaitWithCheckCanceled(future)
+ }
+
+
+ private fun buildTargetCommandLine(blackFormattingRequest: BlackFormattingRequest,
+ targetEnvRequest: TargetEnvironmentRequest): TargetedCommandLine {
+ val cmd = TargetedCommandLineBuilder(targetEnvRequest)
+ val cmdArgs = configToCmdArguments(blackFormattingRequest.virtualFile)
+ val cwd = blackFormattingRequest.virtualFile.parent.takeIf { it.isDirectory }
+ if (cwd != null) {
+ cmd.setWorkingDirectory(cwd.path)
+ }
+
+ when (blackConfig.executionMode) {
+ BlackFormatterConfiguration.ExecutionMode.BINARY -> {
+ val blackExecutable = blackConfig.pathToExecutable
+ if (blackExecutable == null) {
+ throw FileNotFoundException(PyBundle.message("black.empty.path.to.executable.exception.text"))
+ }
+ cmd.setExePath(blackExecutable)
+ }
+ BlackFormatterConfiguration.ExecutionMode.PACKAGE -> {
+ sdk.configureBuilderToRunPythonOnTarget(cmd)
+ cmd.addParameters("-m", BlackFormatterUtil.PACKAGE_NAME)
+ }
+ }
+
+ cmd.addParameters(cmdArgs)
+ cmd.addParameter("-")
+ return cmd.build()
+ }
+
+ private fun configToCmdArguments(vFile: VirtualFile): List {
+ val cmd = mutableListOf()
+ val blackVersion = BlackFormatterVersionService.getVersion(project)
+
+ if (FileTypeRegistry.getInstance().isFileOfType(vFile, PyiFileType.INSTANCE)) {
+ cmd.add("--pyi")
+ }
+
+ if (blackConfig.cmdArguments.isNotEmpty()) {
+ cmd.addAll(blackConfig.cmdArguments.split(" "))
+ }
+
+ if (blackVersion >= minimalStdinFilenameCompatibleVersion) {
+ cmd.add("--stdin-filename")
+ cmd.add(vFile.path)
+ }
+
+ return cmd
+ }
+
+ private fun getFuture(targetCMD: TargetedCommandLine,
+ targetEnvironment: TargetEnvironment,
+ formattingRequest: BlackFormattingRequest,
+ timeout: Duration): CompletableFuture {
+ val vFile = formattingRequest.virtualFile
+
+ val future = CompletableFuture()
+ .completeOnTimeout(BlackFormattingResponse.Failure(PyBundle.message("black.failed.to.format.on.save.error.label",
+ vFile.name), "Timeout exceeded", null),
+ timeout.inWholeMilliseconds, TimeUnit.MILLISECONDS)
+
+ val process = targetEnvironment.createProcess(targetCMD)
+
+ val processHandler = CapturingProcessHandler(process, BlackFormattingService.DEFAULT_CHARSET,
+ targetCMD.getCommandPresentation(targetEnvironment))
+
+ processHandler.addProcessListener(writeToStdinListener(formattingRequest.fragmentToFormat, vFile.charset))
+
+ processHandler.addProcessListener(object : CapturingProcessAdapter() {
+ override fun processTerminated(event: ProcessEvent) {
+ val exitCode = event.exitCode
+ if (exitCode == 0) {
+ if (output.stdout.isEmpty()) {
+ future.complete(BlackFormattingResponse.Ignored(PyBundle.message("black.file.ignored.notification.label"),
+ PyBundle.message("black.file.ignored.notification.message", vFile.name)))
+ }
+ else {
+ future.complete(BlackFormattingResponse.Success(output.stdout))
+ }
+ }
+ else {
+ future.complete(BlackFormattingResponse.Failure(
+ PyBundle.message("black.failed.to.format.on.save.error.label", vFile.name),
+ output.stderr, exitCode))
+ }
+ }
+ })
+ processHandler.startNotify()
+ return future
+ }
+
+ private fun writeToStdinListener(text: String, charset: Charset): ProcessListener {
+ return object : ProcessAdapter() {
+ override fun startNotified(event: ProcessEvent) {
+ try {
+ val processInput = event.processHandler.processInput
+ if (processInput == null) {
+ return
+ }
+ processInput.write(text.toByteArray(charset))
+ processInput.close()
+ }
+ catch (e: IOException) {
+ LOG.error(e)
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/python/src/com/jetbrains/python/black/BlackFormatterUtil.kt b/python/src/com/jetbrains/python/black/BlackFormatterUtil.kt
new file mode 100644
index 000000000000..5671422be7a0
--- /dev/null
+++ b/python/src/com/jetbrains/python/black/BlackFormatterUtil.kt
@@ -0,0 +1,65 @@
+// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+package com.jetbrains.python.black
+
+import com.intellij.execution.configurations.PathEnvironmentVariableUtil
+import com.intellij.execution.target.readableFs.PathInfo
+import com.intellij.openapi.diagnostic.thisLogger
+import com.intellij.openapi.fileTypes.FileTypeRegistry
+import com.intellij.openapi.projectRoots.Sdk
+import com.intellij.openapi.ui.ValidationInfo
+import com.intellij.openapi.util.SystemInfo
+import com.intellij.openapi.vfs.VirtualFile
+import com.jetbrains.python.PyBundle
+import com.jetbrains.python.PythonFileType
+import com.jetbrains.python.packaging.PyPackage
+import com.jetbrains.python.packaging.PyPackageManager
+import com.jetbrains.python.pyi.PyiFileType
+import com.jetbrains.python.sdk.add.target.ValidationRequest
+import com.jetbrains.python.sdk.add.target.validateExecutableFile
+import org.jetbrains.annotations.SystemDependent
+import java.io.File
+
+class BlackFormatterUtil {
+ companion object {
+ val LOG = thisLogger()
+
+ const val PACKAGE_NAME: String = "black"
+
+ fun isFileApplicable(vFile: VirtualFile): Boolean {
+ return FileTypeRegistry.getInstance().isFileOfType(vFile, PythonFileType.INSTANCE)
+ || FileTypeRegistry.getInstance().isFileOfType(vFile, PyiFileType.INSTANCE)
+ }
+
+ fun isBlackFormatterInstalledOnProjectSdk(sdk: Sdk?): Boolean {
+ val packageManager = sdk?.let { PyPackageManager.getInstance(it) }
+ return packageManager?.let {
+ it.packages?.any { pyPackage -> pyPackage.name == PACKAGE_NAME }
+ } ?: false
+ }
+
+ fun getBlackFormatterPackageInfo(sdk: Sdk?): PyPackage? {
+ val packageManager = sdk?.let { PyPackageManager.getInstance(it) }
+ return packageManager?.let {
+ it.refreshAndGetPackages(false).firstOrNull { pyPackage -> pyPackage.name == PACKAGE_NAME }
+ }
+ }
+
+ fun detectBlackExecutable(): File? {
+ val name = when {
+ SystemInfo.isWindows -> "black.exe"
+ else -> "black"
+ }
+ return PathEnvironmentVariableUtil.findInPath(name)
+ }
+
+ fun isBlackExecutableDetected(): Boolean = detectBlackExecutable() != null
+
+ fun validateBlackExecutable(path: @SystemDependent String?): ValidationInfo? {
+ return validateExecutableFile(ValidationRequest(
+ path = path,
+ fieldIsEmpty = PyBundle.message("black.executable.not.found", if (SystemInfo.isWindows) 0 else 1),
+ pathInfoProvider = PathInfo.localPathInfoProvider
+ ))
+ }
+ }
+}
\ No newline at end of file
diff --git a/python/src/com/jetbrains/python/black/BlackFormatterVersionService.kt b/python/src/com/jetbrains/python/black/BlackFormatterVersionService.kt
new file mode 100644
index 000000000000..570ef267d949
--- /dev/null
+++ b/python/src/com/jetbrains/python/black/BlackFormatterVersionService.kt
@@ -0,0 +1,105 @@
+// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+package com.jetbrains.python.black
+
+import com.intellij.execution.process.CapturingProcessHandler
+import com.intellij.execution.target.TargetedCommandLineBuilder
+import com.intellij.execution.target.local.LocalTargetEnvironment
+import com.intellij.execution.target.local.LocalTargetEnvironmentRequest
+import com.intellij.openapi.components.Service
+import com.intellij.openapi.components.service
+import com.intellij.openapi.progress.runBlockingMaybeCancellable
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.util.Version
+import com.intellij.util.VersionUtil
+import com.jetbrains.python.black.configuration.BlackFormatterConfiguration
+import com.jetbrains.python.packaging.PyPackage
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.util.regex.Pattern
+
+@Service(Service.Level.PROJECT)
+class BlackFormatterVersionService(private val project: Project) {
+ companion object {
+
+ private val PATTERN = Pattern.compile("(?<=black, )(([\\d]+\\.[\\d]+\\.?[\\d]*).*)(?= \\(compiled: )")
+
+ val UNKNOWN_VERSION = Version(0, 0, 0)
+
+ private fun getInstance(project: Project): BlackFormatterVersionService = project.service()
+
+ fun getVersion(project: Project): Version = getInstance(project).getVersion()
+ }
+
+ private var version: Version
+
+ private var configuration: BlackFormatterConfiguration
+
+ init {
+ configuration = BlackFormatterConfiguration.getBlackConfiguration(project).copy()
+ version = getVersionFromSdkOrBinary()
+ }
+
+ @Synchronized
+ private fun getVersion(): Version {
+ if (BlackFormatterConfiguration.getBlackConfiguration(project) != configuration) {
+ configuration = BlackFormatterConfiguration.getBlackConfiguration(project).copy()
+ version = getVersionFromSdkOrBinary()
+ }
+ return version
+ }
+
+ private fun getVersionFromSdkOrBinary(): Version {
+ return when (configuration.executionMode) {
+ BlackFormatterConfiguration.ExecutionMode.BINARY -> {
+ configuration.pathToExecutable?.let {
+ getVersionForExecutable(it)
+ } ?: UNKNOWN_VERSION
+ }
+ BlackFormatterConfiguration.ExecutionMode.PACKAGE -> {
+ BlackFormatterUtil.getBlackFormatterPackageInfo(configuration.getSdk(project))?.let {
+ getVersionForPackage(it)
+ } ?: UNKNOWN_VERSION
+ }
+ }
+ }
+
+ private fun getVersionForExecutable(pathToExecutable: String): Version {
+ val targetEnvRequest = LocalTargetEnvironmentRequest()
+ val targetEnvironment = LocalTargetEnvironment(LocalTargetEnvironmentRequest())
+
+ val commandLineBuilder = TargetedCommandLineBuilder(targetEnvRequest)
+ commandLineBuilder.setExePath(pathToExecutable)
+ commandLineBuilder.addParameters("--version")
+
+ val targetCMD = commandLineBuilder.build()
+
+ val process = targetEnvironment.createProcess(targetCMD)
+
+ return runBlockingMaybeCancellable {
+ runCatching {
+ withContext(Dispatchers.IO) {
+ val processHandler = CapturingProcessHandler(process, targetCMD.charset, targetCMD.getCommandPresentation(targetEnvironment))
+ val processOutput = processHandler.runProcess(5000, true).stdout
+ return@withContext VersionUtil.parseVersion(processOutput, PATTERN) ?: UNKNOWN_VERSION
+ }
+ }.getOrDefault(UNKNOWN_VERSION)
+ }
+ }
+
+ private fun getVersionForPackage(pythonPackage: PyPackage): Version =
+ parseVersionString(pythonPackage.version)
+
+ private fun parseVersionString(versionString: String): Version {
+
+ val parts = versionString
+ .split(".", "b")
+ .mapNotNull(String::toIntOrNull)
+ .filter { it >= 0 }
+
+ if (parts.size < 3) {
+ return UNKNOWN_VERSION
+ }
+
+ return Version(parts[0], parts[1], parts[2])
+ }
+}
\ No newline at end of file
diff --git a/python/src/com/jetbrains/python/black/BlackFormattingRequest.kt b/python/src/com/jetbrains/python/black/BlackFormattingRequest.kt
new file mode 100644
index 000000000000..22a440746c49
--- /dev/null
+++ b/python/src/com/jetbrains/python/black/BlackFormattingRequest.kt
@@ -0,0 +1,55 @@
+// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+package com.jetbrains.python.black
+
+import com.intellij.openapi.vfs.VirtualFile
+import com.jetbrains.python.psi.PyIndentUtil
+
+sealed class BlackFormattingRequest {
+ abstract val fragmentToFormat: String
+ abstract val virtualFile: VirtualFile
+
+ class Fragment(val fragment: String, override val virtualFile: VirtualFile) : BlackFormattingRequest() {
+ private val extractedIndent: String
+ private val whitespaceBefore: String
+ private val whitespaceAfter: String
+ private val endsWithNewLine: Boolean
+
+ override val fragmentToFormat: String
+
+ init {
+ val firstNotEmptyLine = fragment.lines().first { it.isNotBlank() }
+
+ extractedIndent = PyIndentUtil.getLineIndent(firstNotEmptyLine)
+ whitespaceBefore = fragment.takeWhile { it.isWhitespace() }
+ whitespaceAfter = fragment.takeLastWhile { it.isWhitespace() }
+ endsWithNewLine = fragment.endsWith("\n")
+
+ fragmentToFormat = PyIndentUtil.removeCommonIndent(fragment, false)
+ }
+
+ fun postProcessResponse(response: String): String {
+ return buildString {
+ val lines = response.trimEnd().lines()
+
+ if (!response.contains('\n')) {
+ append(extractedIndent)
+ append(response)
+ if (endsWithNewLine) {
+ append('\n')
+ }
+ return@buildString
+ }
+
+ append(whitespaceBefore)
+ append(lines.first())
+ for (line in lines.listIterator(1)) {
+ appendLine()
+ append(line.prependIndent(extractedIndent))
+ }
+ append(whitespaceAfter)
+ }
+ }
+ }
+
+ class File(override val fragmentToFormat: String, override val virtualFile: VirtualFile) : BlackFormattingRequest()
+}
\ No newline at end of file
diff --git a/python/src/com/jetbrains/python/black/BlackFormattingResponse.kt b/python/src/com/jetbrains/python/black/BlackFormattingResponse.kt
new file mode 100644
index 000000000000..7a4afc7312d3
--- /dev/null
+++ b/python/src/com/jetbrains/python/black/BlackFormattingResponse.kt
@@ -0,0 +1,29 @@
+// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+package com.jetbrains.python.black
+
+import com.intellij.openapi.util.NlsSafe
+import org.jetbrains.annotations.Nls
+
+sealed class BlackFormattingResponse {
+ class Success(val formattedText: String) : BlackFormattingResponse()
+
+ class Ignored(@Nls val title: String,
+ @NlsSafe val description: String) : BlackFormattingResponse()
+
+ class Failure(@Nls val title: String,
+ @NlsSafe val description: String,
+ val exitCode: Int?) : BlackFormattingResponse() {
+
+ fun getLoggingMessage(): String {
+ val stringBuilder = StringBuilder()
+ stringBuilder.append("${title}\n")
+ if (description.isNotEmpty()) {
+ stringBuilder.append("stderr: ${description}\n")
+ }
+ if (exitCode != null) {
+ stringBuilder.append("exit code: ${exitCode}\n")
+ }
+ return stringBuilder.toString()
+ }
+ }
+}
\ No newline at end of file
diff --git a/python/src/com/jetbrains/python/black/BlackFormattingService.kt b/python/src/com/jetbrains/python/black/BlackFormattingService.kt
new file mode 100644
index 000000000000..be4ae7d4eb4b
--- /dev/null
+++ b/python/src/com/jetbrains/python/black/BlackFormattingService.kt
@@ -0,0 +1,169 @@
+// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+package com.jetbrains.python.black
+
+import com.intellij.codeInsight.actions.VcsFacade
+import com.intellij.codeInsight.hint.HintManager
+import com.intellij.codeInsight.hint.HintManagerImpl
+import com.intellij.codeInsight.hint.HintUtil
+import com.intellij.formatting.service.AsyncDocumentFormattingService
+import com.intellij.formatting.service.AsyncFormattingRequest
+import com.intellij.formatting.service.FormattingService
+import com.intellij.openapi.application.ApplicationManager
+import com.intellij.openapi.application.ModalityState
+import com.intellij.openapi.application.ReadAction
+import com.intellij.openapi.diagnostic.thisLogger
+import com.intellij.openapi.editor.Document
+import com.intellij.openapi.editor.Editor
+import com.intellij.openapi.progress.ProcessCanceledException
+import com.intellij.openapi.util.TextRange
+import com.intellij.openapi.util.registry.Registry
+import com.intellij.psi.PsiDocumentManager
+import com.intellij.psi.PsiFile
+import com.intellij.psi.util.PsiEditorUtil
+import com.intellij.ui.LightweightHint
+import com.jetbrains.python.PyBundle
+import com.jetbrains.python.black.configuration.BlackFormatterConfiguration
+import org.jetbrains.annotations.Nls
+import java.nio.charset.Charset
+import java.nio.charset.StandardCharsets
+import kotlin.time.toKotlinDuration
+
+
+class BlackFormattingService : AsyncDocumentFormattingService() {
+ companion object {
+ private val LOG = thisLogger()
+ val NAME: String = PyBundle.message("black.formatting.service.name")
+ val DEFAULT_CHARSET: Charset = StandardCharsets.UTF_8
+ const val NOTIFICATION_GROUP_ID = "Black Formatter Integration"
+ val FEATURES: Set = setOf(FormattingService.Feature.FORMAT_FRAGMENTS)
+ }
+
+ override fun getFeatures(): Set = FEATURES
+
+ override fun canFormat(source: PsiFile): Boolean {
+ if (!Registry.`is`("black.formatter.support.enabled")) return false
+ val project = source.project
+ val blackConfiguration = BlackFormatterConfiguration.getBlackConfiguration(project)
+
+ BlackFormatterAdvertiserService.getInstance().suggestBlack(project, blackConfiguration)
+
+ if (!blackConfiguration.enabledOnReformat) return false
+ val vFile = source.virtualFile ?: return false
+ return BlackFormatterUtil.isFileApplicable(vFile)
+ }
+
+ override fun createFormattingTask(formattingRequest: AsyncFormattingRequest): FormattingTask? {
+ val formattingContext = formattingRequest.context
+ val file = formattingContext.containingFile
+ val vFile = formattingContext.virtualFile ?: return null
+ val formattingRange = formattingRequest.formattingRanges[0]
+ val project = formattingContext.project
+ val blackConfig = BlackFormatterConfiguration.getBlackConfiguration(project)
+ val sdk = blackConfig.getSdk(project)
+
+ if (sdk == null) {
+ val message = PyBundle.message("black.sdk.not.configured.error", project.name)
+ LOG.warn(message)
+ formattingRequest.onError(PyBundle.message("black.sdk.not.configured.error.title"), message)
+ return null
+ }
+
+ val document = ReadAction.compute {
+ PsiDocumentManager.getInstance(project).getDocument(file)
+ }
+ if (document == null) {
+ LOG.warn("Document for file ${file.name} is null")
+ return null
+ }
+
+ val text = document.text
+ val fragment: String = document.getText(formattingRange)
+ if (fragment.isEmpty()) return null
+
+ val editor = PsiEditorUtil.findEditor(file)
+
+ val blackFormattingRequest = if (isFormatFragmentAction(document, formattingRange))
+ BlackFormattingRequest.Fragment(fragment, vFile)
+ else
+ BlackFormattingRequest.File(fragment, vFile)
+
+ return object : FormattingTask {
+ override fun run() {
+ runCatching {
+ val executor = BlackFormatterExecutor(project, sdk, blackConfig)
+
+ when (val response = executor.getBlackFormattingResponse(blackFormattingRequest, timeout.toKotlinDuration())) {
+ is BlackFormattingResponse.Success -> {
+ val formattedFragment = response.formattedText
+ val formattedDocumentText = when (blackFormattingRequest) {
+ is BlackFormattingRequest.Fragment -> {
+ val postProcessedFragment = blackFormattingRequest.postProcessResponse(formattedFragment)
+ text.replaceRange(IntRange(formattingRange.startOffset, formattingRange.endOffset - 1), postProcessedFragment)
+ }
+ is BlackFormattingRequest.File -> {
+ formattedFragment
+ }
+ }
+ formattingRequest.onTextReady(formattedDocumentText)
+ val message = buildNotificationMessage(document, formattedDocumentText)
+ showFormattedLinesInfo(editor, message)
+ }
+ is BlackFormattingResponse.Failure -> {
+ LOG.warn(response.getLoggingMessage())
+ formattingRequest.onError(response.title, response.description)
+ }
+ is BlackFormattingResponse.Ignored -> {
+ showFormattedLinesInfo(editor, PyBundle.message("black.file.ignored.notification.message", vFile.name))
+ }
+ }
+ }.onFailure { exception ->
+ when (exception) {
+ is ProcessCanceledException -> { /* ignore */ }
+ else -> {
+ LOG.warn(exception)
+ formattingRequest.onError(PyBundle.message("black.exception.error.message"), exception.localizedMessage)
+ }
+ }
+ }
+ }
+
+ override fun cancel(): Boolean {
+ return true
+ }
+
+ override fun isRunUnderProgress(): Boolean {
+ return true
+ }
+ }
+ }
+
+ private fun buildNotificationMessage(document: Document, textBefore: CharSequence): @Nls String {
+ val diff = VcsFacade.getInstance().calculateChangedLinesNumber(document, textBefore)
+ return if (diff == 0)
+ PyBundle.message("black.no.lines.changed")
+ else
+ PyBundle.message("black.formatted.n.lines", diff, if (diff == 1) 1 else 0)
+ }
+
+ private fun showFormattedLinesInfo(editor: Editor?, text: @Nls String) {
+ if (editor != null) {
+ ApplicationManager.getApplication()
+ .invokeLater({
+ val component = HintUtil.createInformationLabel(text, null, null, null)
+ val hint = LightweightHint(component)
+ HintManagerImpl.getInstanceImpl()
+ .showEditorHint(hint, editor, HintManager.ABOVE,
+ HintManager.HIDE_BY_ANY_KEY or HintManager.HIDE_BY_SCROLLING, 0,
+ false)
+ },
+ ModalityState.defaultModalityState()) { editor.isDisposed || !editor.component.isShowing }
+ }
+ }
+
+ private fun isFormatFragmentAction(document: Document, range: TextRange): Boolean =
+ range.length != document.textLength
+
+ override fun getNotificationGroupId(): String = NOTIFICATION_GROUP_ID
+
+ override fun getName(): String = NAME
+}
\ No newline at end of file
diff --git a/python/src/com/jetbrains/python/black/configuration/BlackFormatterConfigurable.kt b/python/src/com/jetbrains/python/black/configuration/BlackFormatterConfigurable.kt
new file mode 100644
index 000000000000..0bc43c5ad701
--- /dev/null
+++ b/python/src/com/jetbrains/python/black/configuration/BlackFormatterConfigurable.kt
@@ -0,0 +1,425 @@
+// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+package com.jetbrains.python.black.configuration
+
+import com.intellij.codeInsight.AutoPopupController
+import com.intellij.icons.AllIcons
+import com.intellij.ide.actionsOnSave.*
+import com.intellij.openapi.actionSystem.ActionManager
+import com.intellij.openapi.actionSystem.IdeActions
+import com.intellij.openapi.application.EDT
+import com.intellij.openapi.editor.SpellCheckingEditorCustomizationProvider
+import com.intellij.openapi.editor.ex.EditorEx
+import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory
+import com.intellij.openapi.keymap.KeymapUtil
+import com.intellij.openapi.options.BoundConfigurable
+import com.intellij.openapi.progress.runBlockingModal
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.project.modules
+import com.intellij.openapi.projectRoots.Sdk
+import com.intellij.openapi.ui.*
+import com.intellij.openapi.util.SystemInfo
+import com.intellij.ui.EnumComboBoxModel
+import com.intellij.ui.SimpleListCellRenderer
+import com.intellij.ui.TextFieldWithAutoCompletionListProvider
+import com.intellij.ui.dsl.builder.*
+import com.intellij.util.io.await
+import com.intellij.util.text.nullize
+import com.intellij.util.textCompletion.TextCompletionUtil
+import com.intellij.util.textCompletion.TextFieldWithCompletion
+import com.intellij.util.ui.UIUtil
+import com.intellij.webcore.packaging.PackageManagementService
+import com.jetbrains.python.PyBundle
+import com.jetbrains.python.black.BlackFormatterUtil
+import com.jetbrains.python.black.BlackFormatterVersionService
+import com.jetbrains.python.black.configuration.BlackFormatterConfiguration.BlackFormatterOption.Companion.toCliOptionFlags
+import com.jetbrains.python.newProject.steps.createPythonSdkComboBox
+import com.jetbrains.python.packaging.PyPackageManagers
+import com.jetbrains.python.packaging.PyPackagesNotificationPanel
+import com.jetbrains.python.sdk.PythonSdkAdditionalData
+import com.jetbrains.python.sdk.pythonSdk
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import org.jetbrains.annotations.Nls
+import java.io.File
+import java.util.concurrent.CompletableFuture
+import javax.swing.JButton
+import javax.swing.JCheckBox
+import javax.swing.JLabel
+
+const val CONFIGURABLE_ID = "com.jetbrains.python.black.configuration.BlackFormatterConfigurable"
+
+class BlackFormatterConfigurable(val project: Project) : BoundConfigurable(PyBundle.message("black.configurable.name")) {
+
+ private var storedState: BlackFormatterConfiguration
+
+ private var isBlackFormatterPackageInstalled: Boolean = false
+ private var detectedBlackExecutable: File? = null
+ private var selectedSdk: Sdk? = null
+ private var isLocalSdk = false
+
+ private lateinit var enableOnReformatCheckBox: JCheckBox
+ private lateinit var enableOnSaveCheckBox: JCheckBox
+ private lateinit var packageNotInstalledErrorLabel: JLabel
+ private lateinit var remoteSdkErrorLabel: JLabel
+ private lateinit var installButton: JButton
+ private lateinit var installPanel: Panel
+ private lateinit var settingsPanel: Panel
+ private lateinit var pathToBinaryRow: Row
+ private lateinit var sdkSelectionRow: Row
+ private lateinit var cliArgumentsRow: Row
+ private lateinit var executionModeComboBox: ComboBox
+
+ private val blackExecutablePathField = TextFieldWithBrowseButton().apply {
+ addBrowseFolderListener(
+ @Suppress("DialogTitleCapitalization")
+ PyBundle.message("black.select.path.to.executable"),
+ null,
+ project,
+ FileChooserDescriptorFactory.createSingleFileOrExecutableAppDescriptor()
+ )
+ }
+
+ private val sdkSelectionComboBox = createPythonSdkComboBox(project.modules.mapNotNull { it.pythonSdk }, null)
+
+ private val cliArgumentsTextField = BlackTextFieldWithAutoCompletion(project, object :
+ TextFieldWithAutoCompletionListProvider(
+ BlackFormatterConfiguration.options.toCliOptionFlags()) {
+
+ override fun getLookupString(item: BlackFormatterConfiguration.CliOptionFlag): String = item.flag + " "
+
+ override fun getTailText(item: BlackFormatterConfiguration.CliOptionFlag): String? = item.option.param
+
+ override fun getTypeText(item: BlackFormatterConfiguration.CliOptionFlag): String = item.description()
+ })
+
+ var mainPanel: DialogPanel = panel {
+ row {
+ label(PyBundle.message("black.execution.mode.label"))
+ .applyToComponent { toolTipText = PyBundle.message("black.execution.mode.tooltip.text") }
+ .applyToComponent { icon = AllIcons.General.ContextHelp }
+ executionModeComboBox = comboBox(EnumComboBoxModel(
+ BlackFormatterConfiguration.ExecutionMode::class.java))
+ .applyToComponent { renderer = executionModeComboBoxRenderer }
+ .component
+ layout(RowLayout.LABEL_ALIGNED)
+ }
+ row {
+ remoteSdkErrorLabel = label(PyBundle.message("black.remote.sdk.error"))
+ .applyToComponent { icon = AllIcons.General.Warning }
+ .visible(false)
+ .component
+ }
+ pathToBinaryRow = row(PyBundle.message("black.executable.label")) {
+ layout(RowLayout.LABEL_ALIGNED)
+ cell(blackExecutablePathField)
+ .validationInfo { blackExecutableValidationInfo() }
+ .onChanged { updateUiState() }
+ .align(AlignX.FILL)
+ bottomGap(BottomGap.SMALL)
+ }
+ sdkSelectionRow = row(PyBundle.message("black.sdk.selection.combobox.label")) {
+ layout(RowLayout.LABEL_ALIGNED)
+ cell(sdkSelectionComboBox)
+ .resizableColumn()
+ .align(AlignX.FILL)
+ .columns(COLUMNS_SHORT)
+ }
+ installPanel = panel {
+ row {
+ packageNotInstalledErrorLabel = label(PyBundle.message("black.not.installed.error"))
+ .applyToComponent { icon = AllIcons.General.Warning }
+ .component
+ installButton = button(PyBundle.message("black.install.button.label")) {
+ runBlockingModal(project, PyBundle.message("black.installing.modal.title")) {
+ withContext(Dispatchers.EDT) {
+ if (selectedSdk != null) {
+ val errorDescription = installBlackFormatter(selectedSdk!!)
+ if (errorDescription == null) {
+ isBlackFormatterPackageInstalled = true
+ enableOnReformatCheckBox.isSelected = true
+ updateUiState()
+ }
+ else {
+ PyPackagesNotificationPanel
+ .showPackageInstallationError(@Suppress("DialogTitleCapitalization")
+ PyBundle.message("black.installation.error.title"),
+ errorDescription)
+ }
+ }
+ }
+ }
+ }.component
+ }
+ }
+ settingsPanel = panel {
+ row(PyBundle.message("black.use.section.label")) {
+ layout(RowLayout.LABEL_ALIGNED)
+ enableOnReformatCheckBox = checkBox(PyBundle.message("black.enable.black.checkbox.label")).component
+ val shortcut = ActionManager.getInstance().getKeyboardShortcut(IdeActions.ACTION_EDITOR_REFORMAT)
+ shortcut?.let { comment(KeymapUtil.getShortcutText(it)) }
+ }
+ row {
+ label("")
+ layout(RowLayout.LABEL_ALIGNED)
+ bottomGap(BottomGap.SMALL)
+ enableOnSaveCheckBox = checkBox(PyBundle.message("black.enable.action.on.save.label")).component
+ val link = ActionsOnSaveConfigurable.createGoToActionsOnSavePageLink()
+ cell(link)
+ }
+ cliArgumentsRow = row(PyBundle.message("black.cli.args.text.field.label")) {
+ cell(cliArgumentsTextField)
+ .resizableColumn()
+ .align(AlignX.FILL)
+ .applyToComponent {
+ background = UIUtil.getTextFieldBackground()
+ }
+ .comment(PyBundle.message("black.cli.args.comment"), MAX_LINE_LENGTH_WORD_WRAP)
+ }
+ }
+ }
+
+ init {
+ storedState = BlackFormatterConfiguration.getBlackConfiguration(project)
+
+ selectedSdk = storedState.getSdk(project)
+ ?: if (project.modules.size == 1) project.pythonSdk
+ else null
+
+ updateSdkInfo()
+
+ detectedBlackExecutable = BlackFormatterUtil.detectBlackExecutable()
+
+ executionModeComboBox.addActionListener { updateUiState() }
+
+ sdkSelectionComboBox.addActionListener {
+ selectedSdk = sdkSelectionComboBox.item
+ updateSdkInfo()
+ updateUiState()
+ }
+ }
+
+ private fun initForm() {
+ enableOnReformatCheckBox.isSelected = storedState.enabledOnReformat
+ enableOnSaveCheckBox.isSelected = storedState.enabledOnSave
+ executionModeComboBox.item = storedState.executionMode
+ cliArgumentsTextField.text = storedState.cmdArguments ?: ""
+ sdkSelectionComboBox.item = selectedSdk
+
+ blackExecutablePathField.emptyText.text = getBlackExecPathPlaceholderMessage()
+ storedState.pathToExecutable?.let {
+ blackExecutablePathField.text = it
+ }
+
+ if (storedState == BlackFormatterConfiguration()) {
+ if (isBlackFormatterPackageInstalled) {
+ executionModeComboBox.selectedItem = BlackFormatterConfiguration.ExecutionMode.PACKAGE
+ }
+ else if (detectedBlackExecutable != null) {
+ executionModeComboBox.selectedItem = BlackFormatterConfiguration.ExecutionMode.BINARY
+ }
+ }
+
+ updateUiState()
+ }
+
+ private fun updateUiState() {
+ val isBinaryMode = executionModeComboBox.selectedItem == BlackFormatterConfiguration.ExecutionMode.BINARY
+
+ installPanel.visible(!isBlackFormatterPackageInstalled && !isBinaryMode && isLocalSdk)
+ pathToBinaryRow.visible(isBinaryMode)
+ sdkSelectionRow.visible(!isBinaryMode)
+
+ if (selectedSdk == null) {
+ packageNotInstalledErrorLabel.text = PyBundle.message("black.no.project.interpreter.error")
+ installButton.isVisible = false
+ }
+ else if (!isLocalSdk) {
+ remoteSdkErrorLabel.isVisible = executionModeComboBox.selectedItem == BlackFormatterConfiguration.ExecutionMode.PACKAGE
+ }
+
+ val canBeEnabled = canBeEnabled()
+ settingsPanel.enabled(canBeEnabled)
+ enableOnReformatCheckBox.isSelected = storedState.enabledOnReformat && canBeEnabled
+ enableOnSaveCheckBox.isSelected = storedState.enabledOnSave && canBeEnabled
+ }
+
+ private fun updateSdkInfo() {
+ isLocalSdk = selectedSdk?.let { it.sdkType.isLocalSdk(it) } ?: false
+ isBlackFormatterPackageInstalled = BlackFormatterUtil.isBlackFormatterInstalledOnProjectSdk(selectedSdk)
+ }
+
+ private suspend fun installBlackFormatter(sdk: Sdk): PackageManagementService.ErrorDescription? {
+ val manager = PyPackageManagers.getInstance().getManagementService(project, sdk)
+ val blackPackage = manager.allPackagesCached.firstOrNull { pyPackage -> pyPackage.name == BlackFormatterUtil.PACKAGE_NAME }
+ val result = CompletableFuture()
+ val listener = object : PackageManagementService.Listener {
+ override fun operationStarted(packageName: String?) {}
+
+ override fun operationFinished(packageName: String?, errorDescription: PackageManagementService.ErrorDescription?) {
+ if (errorDescription == null) {
+ result.complete(null)
+ }
+ else {
+ result.complete(errorDescription)
+ }
+ }
+ }
+ manager.installPackage(blackPackage, null, false, null, listener, false)
+ return result.await()
+ }
+
+ private fun canBeEnabled(): Boolean {
+ return when (executionModeComboBox.selectedItem) {
+ BlackFormatterConfiguration.ExecutionMode.BINARY ->
+ detectedBlackExecutable != null || storedState.pathToExecutable != null || blackExecutableValidationInfo() == null
+ BlackFormatterConfiguration.ExecutionMode.PACKAGE ->
+ selectedSdk != null && isLocalSdk && isBlackFormatterPackageInstalled
+ else -> false
+ }
+ }
+
+ private fun applyToConfig(configuration: BlackFormatterConfiguration): BlackFormatterConfiguration = configuration.apply {
+ executionMode = executionModeComboBox.item
+ enabledOnReformat = enableOnReformatCheckBox.isSelected
+ enabledOnSave = enableOnSaveCheckBox.isSelected
+ cmdArguments = cliArgumentsTextField.text
+ sdkUUID = (sdkSelectionComboBox.item?.sdkAdditionalData as? PythonSdkAdditionalData)?.uuid.toString()
+
+ pathToExecutable = if (blackExecutableValidationInfo() == null) {
+ blackExecutablePathField.text.nullize() ?: BlackFormatterUtil.detectBlackExecutable()?.absolutePath
+ }
+ else null
+ }
+
+ private fun blackExecutableValidationInfo(): ValidationInfo? =
+ BlackFormatterUtil.validateBlackExecutable(
+ blackExecutablePathField.text.nullize() ?: BlackFormatterUtil.detectBlackExecutable()?.absolutePath)
+
+ private fun getBlackExecPathPlaceholderMessage(): String {
+ return BlackFormatterUtil.detectBlackExecutable()?.let {
+ PyBundle.message("black.executable.auto.detected.path", it.absolutePath)
+ } ?: PyBundle.message("black.executable.not.found", if (SystemInfo.isWindows) 0 else 1)
+ }
+
+ override fun isModified(): Boolean = storedState != applyToConfig(storedState.copy())
+
+ override fun reset() {
+ initForm()
+ updateUiState()
+ }
+
+ override fun apply() {
+ applyToConfig(storedState)
+ }
+ override fun createPanel(): DialogPanel {
+ mainPanel.registerValidators(disposable!!)
+ return mainPanel
+ }
+
+ companion object {
+ private val executionModeComboBoxRenderer =
+ SimpleListCellRenderer.create(
+ SimpleListCellRenderer.Customizer { label, value, _ ->
+ val text: @Nls String = when (value) {
+ BlackFormatterConfiguration.ExecutionMode.PACKAGE -> PyBundle.message("black.execution.mode.package")
+ BlackFormatterConfiguration.ExecutionMode.BINARY -> PyBundle.message("black.execution.mode.binary")
+ null -> ""
+ }
+ label.text = text
+ })
+ }
+
+
+ class BlackTextFieldWithAutoCompletion(project: Project,
+ provider: TextFieldWithAutoCompletionListProvider)
+ : TextFieldWithCompletion(project, provider, "", true, true, true) {
+ override fun createEditor(): EditorEx {
+ val editor = super.createEditor()
+ val disableSpellChecking = SpellCheckingEditorCustomizationProvider.getInstance().disabledCustomization
+ disableSpellChecking?.customize(editor)
+ editor.putUserData(AutoPopupController.ALWAYS_AUTO_POPUP, true)
+ val completionShortcut = KeymapUtil.getFirstKeyboardShortcutText(
+ ActionManager.getInstance().getAction(IdeActions.ACTION_CODE_COMPLETION))
+ if (completionShortcut.isNotEmpty()) {
+ TextCompletionUtil.installCompletionHint(editor)
+ }
+ return editor
+ }
+
+ override fun getText(): String = super.getText().trimEnd()
+ }
+
+ class BlackFormatterActionOnSaveInfoProvider : ActionOnSaveInfoProvider() {
+ override fun getActionOnSaveInfos(context: ActionOnSaveContext):
+ List = listOf(BlackFormatterActionOnSaveInfo(context))
+
+ override fun getSearchableOptions(): Collection {
+ return listOf(PyBundle.message("black.action.on.save.name"), PyBundle.message("black.configurable.name"))
+ }
+ }
+
+ internal class BlackFormatterActionOnSaveInfo(actionOnSaveContext: ActionOnSaveContext)
+ : ActionOnSaveBackedByOwnConfigurable(actionOnSaveContext, CONFIGURABLE_ID,
+ BlackFormatterConfigurable::class.java) {
+
+ override fun setActionOnSaveEnabled(configurable: BlackFormatterConfigurable, enabled: Boolean) {
+ configurable.enableOnSaveCheckBox.isSelected = enabled
+ }
+
+ override fun getCommentAccordingToStoredState() =
+ getCommentForBlack(BlackFormatterConfiguration.getBlackConfiguration(project))
+
+ override fun getCommentAccordingToUiState(configurable: BlackFormatterConfigurable) =
+ getCommentForBlack(configurable.storedState)
+
+ private fun getCommentForBlack(configuration: BlackFormatterConfiguration): ActionOnSaveComment {
+ val version = runBlockingModal(project, "") {
+ BlackFormatterVersionService.getVersion(project)
+ }
+ return when (configuration.executionMode) {
+ BlackFormatterConfiguration.ExecutionMode.BINARY -> {
+ configuration.pathToExecutable?.let {
+ ActionOnSaveComment.info(PyBundle.message("black.action.on.save.executable.info", version, it))
+ } ?: ActionOnSaveComment.warning(PyBundle.message("black.action.on.save.executable.path.not.specified"))
+ }
+ BlackFormatterConfiguration.ExecutionMode.PACKAGE -> {
+ if (version != BlackFormatterVersionService.UNKNOWN_VERSION) {
+ ActionOnSaveComment.info(PyBundle.message("black.action.on.save.package.info", version))
+ }
+ else {
+ ActionOnSaveComment.warning(PyBundle.message("black.not.installed.error"))
+ }
+ }
+ }
+ }
+
+ override fun isActionOnSaveEnabledAccordingToUiState(configurable: BlackFormatterConfigurable): Boolean {
+ return configurable.enableOnSaveCheckBox.isSelected
+ }
+
+ override fun isActionOnSaveEnabledAccordingToStoredState(): Boolean =
+ BlackFormatterConfiguration.getBlackConfiguration(project).enabledOnSave
+
+ override fun getActionOnSaveName(): String = PyBundle.message("black.action.on.save.name")
+
+ override fun isApplicableAccordingToUiState(configurable: BlackFormatterConfigurable) =
+ when (configurable.storedState.executionMode) {
+ BlackFormatterConfiguration.ExecutionMode.PACKAGE ->
+ BlackFormatterUtil.isBlackFormatterInstalledOnProjectSdk(configurable.selectedSdk)
+ BlackFormatterConfiguration.ExecutionMode.BINARY ->
+ BlackFormatterUtil.isBlackExecutableDetected()
+ }
+
+ override fun isApplicableAccordingToStoredState(): Boolean {
+ val configuration = BlackFormatterConfiguration.getBlackConfiguration(project)
+ return when (configuration.executionMode) {
+ BlackFormatterConfiguration.ExecutionMode.PACKAGE ->
+ BlackFormatterUtil.isBlackFormatterInstalledOnProjectSdk(configuration.getSdk(project))
+ BlackFormatterConfiguration.ExecutionMode.BINARY ->
+ BlackFormatterUtil.isBlackExecutableDetected()
+ }
+ }
+
+ override fun getActionLinks() = listOf(createGoToPageInSettingsLink(CONFIGURABLE_ID))
+ }
+}
\ No newline at end of file
diff --git a/python/src/com/jetbrains/python/black/configuration/BlackFormatterConfigurableProvider.kt b/python/src/com/jetbrains/python/black/configuration/BlackFormatterConfigurableProvider.kt
new file mode 100644
index 000000000000..30e0937b7792
--- /dev/null
+++ b/python/src/com/jetbrains/python/black/configuration/BlackFormatterConfigurableProvider.kt
@@ -0,0 +1,17 @@
+// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+package com.jetbrains.python.black.configuration
+
+import com.intellij.openapi.options.ConfigurableProvider
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.util.registry.Registry
+
+class BlackFormatterConfigurableProvider(val project: Project) : ConfigurableProvider() {
+
+ override fun canCreateConfigurable(): Boolean {
+ return Registry.`is`("black.formatter.support.enabled")
+ }
+
+ override fun createConfigurable(): BlackFormatterConfigurable {
+ return BlackFormatterConfigurable(project)
+ }
+}
\ No newline at end of file
diff --git a/python/src/com/jetbrains/python/black/configuration/BlackFormatterConfiguration.kt b/python/src/com/jetbrains/python/black/configuration/BlackFormatterConfiguration.kt
new file mode 100644
index 000000000000..21c3d27e2577
--- /dev/null
+++ b/python/src/com/jetbrains/python/black/configuration/BlackFormatterConfiguration.kt
@@ -0,0 +1,92 @@
+// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+package com.jetbrains.python.black.configuration
+
+import com.intellij.openapi.components.PersistentStateComponent
+import com.intellij.openapi.components.State
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.project.modules
+import com.intellij.openapi.projectRoots.Sdk
+import com.intellij.util.xmlb.XmlSerializerUtil
+import com.jetbrains.python.sdk.PythonSdkAdditionalData
+import com.jetbrains.python.sdk.pythonSdk
+import java.util.*
+
+const val BLACK_ID: String = "Black"
+
+@State(name = BLACK_ID)
+data class BlackFormatterConfiguration(var enabledOnReformat: Boolean,
+ var enabledOnSave: Boolean,
+ var executionMode: ExecutionMode,
+ var pathToExecutable: String?,
+ var cmdArguments: String,
+ var sdkUUID: String?)
+ : PersistentStateComponent {
+
+ @Suppress("unused") // Empty constructor required for state components
+ constructor() : this(false,
+ false,
+ ExecutionMode.PACKAGE,
+ null,
+ "",
+ null)
+
+ enum class ExecutionMode {
+ BINARY,
+ PACKAGE,
+ }
+
+ fun getSdk(project: Project): Sdk? = sdkUUID?.let { uuidString ->
+ val uuid = UUID.fromString(uuidString)
+ project.modules
+ .mapNotNull { it.pythonSdk }
+ .firstOrNull { sdk -> (sdk.sdkAdditionalData as PythonSdkAdditionalData).uuid == uuid }
+ }
+
+ companion object {
+
+ val options = listOf(
+ BlackFormatterOption(listOf("-l", "--line-length"), "", "How many characters per line to allow. [default: 88]"),
+ BlackFormatterOption(listOf("-x", "--skip-source-first-line"), null, "Skip the first line of the source code"),
+ BlackFormatterOption(listOf("-S", "--skip-string-normalization"), null, "Don't normalize string quotes or prefixes"),
+ BlackFormatterOption(listOf("-C", "--skip-magic-trailing-comma"), null, "Don't use trailing commas as a reason to split lines"),
+ BlackFormatterOption(listOf("--fast", "--safe"), null, "Skip temporary sanity checks [default: --safe]"),
+ BlackFormatterOption(listOf("--config"), "FILE", "Read configuration from FILE path."),
+ BlackFormatterOption(listOf("--preview"), null, "Enable potentially disruptive style changes\n" +
+ "that may be added to Black's main\n" +
+ "functionality in the next major release."),
+ BlackFormatterOption(listOf("-t", "--target-version"), "[ver1, ver2..]", "Python versions that should be supported by\n" +
+ "Black's output. By default, Black will try\n" +
+ "to infer this from the project metadata in\n" +
+ "pyproject.toml. If this does not yield\n" +
+ "conclusive results, Black will use per-file\n" +
+ "auto-detection."),
+ )
+
+ fun getBlackConfiguration(project: Project): BlackFormatterConfiguration = project.getService(BlackFormatterConfiguration::class.java)
+ }
+
+ class CliOptionFlag(val flag: String, val option: BlackFormatterOption) {
+ internal fun description(): String {
+ if (isPrimaryFlag(flag)) {
+ return option.description
+ }
+ val primaryFlag = option.flags.find(::isPrimaryFlag)
+ return if (primaryFlag != null) "See $primaryFlag" else option.description
+ }
+
+ private fun isPrimaryFlag(flag: String): Boolean = flag.startsWith("--")
+ }
+
+ data class BlackFormatterOption(val flags: List, val param: String?, val description: String) {
+
+ companion object {
+ fun List.toCliOptionFlags() = this.flatMap { option ->
+ option.flags.map { CliOptionFlag(it, option) }
+ }
+ }
+ }
+
+ override fun getState(): BlackFormatterConfiguration = this
+
+ override fun loadState(state: BlackFormatterConfiguration) = XmlSerializerUtil.copyBean(state, this)
+}
\ No newline at end of file
diff --git a/python/testSrc/com/jetbrains/env/PyEnvTestCase.java b/python/testSrc/com/jetbrains/env/PyEnvTestCase.java
index cd8638819769..916f77aee7a6 100644
--- a/python/testSrc/com/jetbrains/env/PyEnvTestCase.java
+++ b/python/testSrc/com/jetbrains/env/PyEnvTestCase.java
@@ -188,7 +188,11 @@ public abstract class PyEnvTestCase {
final EnvTestTagsRequired classAnnotation = getClass().getAnnotation(EnvTestTagsRequired.class);
EnvTestTagsRequired methodAnnotation = null;
try {
- final Method method = getClass().getMethod(myTestName.getMethodName());
+ String methodName = myTestName.getMethodName();
+ if (methodName.contains("[")) {
+ methodName = methodName.substring(0, methodName.indexOf('['));
+ }
+ final Method method = getClass().getMethod(methodName);
methodAnnotation = method.getAnnotation(EnvTestTagsRequired.class);
}
catch (final NoSuchMethodException e) {