PY-30757 Black formatter integration support for PyCharm

Merge-request: IJ-MR-107500
Merged-by: Daniil Kalinin <Daniil.Kalinin@jetbrains.com>

GitOrigin-RevId: f0e622057a9533d89d2d47b032b5388f8c991821
This commit is contained in:
Daniil Kalinin
2023-06-18 23:11:33 +00:00
committed by intellij-monorepo-bot
parent a834a7647e
commit 62df4deaf4
15 changed files with 1408 additions and 4 deletions
@@ -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. <a href='https://black.readthedocs.io/en/stable/usage_and_configuration/the_basics.html#command-line-options'>Learn more</a>
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:
+2 -2
View File
@@ -154,8 +154,8 @@ envs {
createPython("python3.11",
python311version,
[],
"python3.11",
["black == 23.1.0"],
"python3.11\nblack",
true)
createPython("python3.12",
@@ -512,6 +512,29 @@
<registryKey key="python.new.run.config" defaultValue="true" description="Python Run configurations new UI"/>
<!-- Black -->
<projectConfigurable groupId="tools"
provider="com.jetbrains.python.black.configuration.BlackFormatterConfigurableProvider"
id="com.jetbrains.python.black.configuration.BlackFormatterConfigurable"
bundle="messages.PyBundle"
key="black.configurable.name"
nonDefaultProject="true"/>
<registryKey key="black.formatter.support.enabled" defaultValue="false"
description="Enable Black formatter support"/>
<formattingService implementation="com.jetbrains.python.black.BlackFormattingService"/>
<actionOnSave id="BlackFormatterActionOnSave"
implementation="com.jetbrains.python.black.BlackFormatterActionOnSave"
order="after FormatOnSaveAction"/>
<actionOnSaveInfoProvider id="BlackFormatterActionOnSaveInfo"
implementation="com.jetbrains.python.black.configuration.BlackFormatterConfigurable$BlackFormatterActionOnSaveInfoProvider"/>
<projectService serviceImplementation="com.jetbrains.python.black.configuration.BlackFormatterConfiguration"/>
<notificationGroup id="Black Formatter Integration" displayType="STICKY_BALLOON" isLogByDefault="true" bundle="messages.PyBundle"
key="black.configurable.name"/>
<dependencyCollector kind="python" implementation="com.jetbrains.python.packaging.PyDependencyCollector"/>
</extensions>
@@ -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<Document?>) {
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<Document>) {
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)
}
@@ -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
}
}
@@ -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<String> {
val cmd = mutableListOf<String>()
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<BlackFormattingResponse> {
val vFile = formattingRequest.virtualFile
val future = CompletableFuture<BlackFormattingResponse>()
.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)
}
}
}
}
}
@@ -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
))
}
}
}
@@ -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])
}
}
@@ -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()
}
@@ -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()
}
}
}
@@ -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<FormattingService.Feature> = setOf(FormattingService.Feature.FORMAT_FRAGMENTS)
}
override fun getFeatures(): Set<FormattingService.Feature> = 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<Document?, RuntimeException> {
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
}
@@ -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<BlackFormatterConfiguration.ExecutionMode>
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.CliOptionFlag>(
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<PackageManagementService.ErrorDescription>()
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<BlackFormatterConfiguration.ExecutionMode>(
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<BlackFormatterConfiguration.CliOptionFlag>)
: 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<ActionOnSaveInfo> = listOf(BlackFormatterActionOnSaveInfo(context))
override fun getSearchableOptions(): Collection<String> {
return listOf(PyBundle.message("black.action.on.save.name"), PyBundle.message("black.configurable.name"))
}
}
internal class BlackFormatterActionOnSaveInfo(actionOnSaveContext: ActionOnSaveContext)
: ActionOnSaveBackedByOwnConfigurable<BlackFormatterConfigurable>(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))
}
}
@@ -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)
}
}
@@ -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<BlackFormatterConfiguration> {
@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"), "<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<String>, val param: String?, val description: String) {
companion object {
fun List<BlackFormatterOption>.toCliOptionFlags() = this.flatMap { option ->
option.flags.map { CliOptionFlag(it, option) }
}
}
}
override fun getState(): BlackFormatterConfiguration = this
override fun loadState(state: BlackFormatterConfiguration) = XmlSerializerUtil.copyBean(state, this)
}
+5 -1
View File
@@ -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) {