diff --git a/python/pluginCore/resources/META-INF/plugin.xml b/python/pluginCore/resources/META-INF/plugin.xml index ad5c58bf6cbc..c1671891c80d 100644 --- a/python/pluginCore/resources/META-INF/plugin.xml +++ b/python/pluginCore/resources/META-INF/plugin.xml @@ -1,5 +1,5 @@ - + @@ -69,4 +69,40 @@ The Python plug-in provides smart editing for Python scripts. The feature set of + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/python/pluginResources/messages/PyBundle.properties b/python/pluginResources/messages/PyBundle.properties index f9651fe0449f..cea24e529da0 100644 --- a/python/pluginResources/messages/PyBundle.properties +++ b/python/pluginResources/messages/PyBundle.properties @@ -378,9 +378,6 @@ python.sdk.poetry.pip.file.lock.not.found=poetry.lock is not found python.sdk.poetry.pip.file.lock.out.of.date=poetry.lock is out of date python.sdk.poetry.pip.file.notification.content=Run poetry lock or poetry update python.sdk.poetry.pip.file.notification.content.without.updating=Run poetry lock, poetry lock --no-update or poetry update -python.sdk.poetry.pip.file.notification.locking=Locking poetry.lock -python.sdk.poetry.pip.file.notification.locking.without.updating=Locking poetry.lock without updating -python.sdk.poetry.pip.file.notification.updating=Updating Poetry environment python.sdk.poetry.pip.file.watcher=pyproject.toml Watcher python.sdk.dialog.title.setting.up.poetry.environment=Setting up poetry environment python.sdk.intention.family.name.install.requirements.from.poetry.lock=Install requirements from poetry.lock @@ -1601,6 +1598,12 @@ python.toolwindow.packages.collapse.all.action=Collapse All django.template.language=Template Language python.error=Error +action.UvSyncAction.text=Uv Sync +action.UvLockAction.text=Uv Lock +action.PoetryUpdateAction.text=Poetry Update +action.PoetryLockAction.text=Poetry Lock +action.HatchRunAction.text=Hatch Run (Sync Dependencies) + python.survey.user.job.notification.group=PyCharm Job Survey python.survey.user.job.notification.title=Feedback In IDE diff --git a/python/python-hatch/src/com/intellij/python/hatch/cli/HatchCli.kt b/python/python-hatch/src/com/intellij/python/hatch/cli/HatchCli.kt index bb99b8f40460..9a9126684a79 100644 --- a/python/python-hatch/src/com/intellij/python/hatch/cli/HatchCli.kt +++ b/python/python-hatch/src/com/intellij/python/hatch/cli/HatchCli.kt @@ -146,19 +146,16 @@ class HatchCli(private val runtime: HatchRuntime) { /** * Run commands within project environments */ - suspend fun run(envName: String, vararg command: String): Result { - val envRuntime = runtime.withEnv(HatchConstants.AppEnvVars.ENV to envName) + suspend fun run(envName: String? = null, vararg command: String): Result { + val envRuntime = envName?.let { runtime.withEnv(HatchConstants.AppEnvVars.ENV to it) } ?: runtime return envRuntime.executeAndHandleErrors("run", *command) { output -> + if (output.exitCode != 0) return@executeAndHandleErrors Result.failure(null) + val scenario = output.stderr.trim() - val content = when { - output.exitCode == 0 -> { - val installDetailsContent = output.stdout.replace("─", "").trim() - val info = installDetailsContent.lines().drop(1).dropLast(2).joinToString("\n") - "$scenario\n$info" - } - else -> scenario - } - Result.success(content) + val installDetailsContent = output.stdout.replace("─", "").trim() + val info = installDetailsContent.lines().drop(1).dropLast(2).joinToString("\n") + + Result.success("$scenario\n$info") } } diff --git a/python/python-hatch/src/com/intellij/python/hatch/hatch.kt b/python/python-hatch/src/com/intellij/python/hatch/hatch.kt index d4ab241eacef..cb587dfd2879 100644 --- a/python/python-hatch/src/com/intellij/python/hatch/hatch.kt +++ b/python/python-hatch/src/com/intellij/python/hatch/hatch.kt @@ -15,6 +15,8 @@ import com.jetbrains.python.errorProcessing.PyResult import com.jetbrains.python.sdk.basePath import java.nio.file.Path +const val HATCH_TOML: String = "hatch.toml" + sealed class HatchError(message: @NlsSafe String) : MessageError(message) class HatchExecutableNotFoundHatchError(path: Path?) : HatchError( @@ -80,7 +82,7 @@ data class ProjectStructure( interface HatchService { fun getWorkingDirectoryPath(): Path - suspend fun syncDependencies(envName: String): PyResult + suspend fun syncDependencies(envName: String? = null): PyResult suspend fun isHatchManagedProject(): PyResult @@ -98,8 +100,8 @@ interface HatchService { /** * Hatch Service for working directory (where hatch.toml / pyproject.toml is usually placed) */ -suspend fun Path.getHatchService(hatchExecutablePath: Path? = null): PyResult { - return CliBasedHatchService(hatchExecutablePath = hatchExecutablePath, workingDirectoryPath = this) +suspend fun Path?.getHatchService(hatchExecutablePath: Path? = null, hatchEnvironmentName: String? = null): PyResult { + return CliBasedHatchService(hatchExecutablePath = hatchExecutablePath, workingDirectoryPath = this, hatchEnvironmentName = hatchEnvironmentName) } /** diff --git a/python/python-hatch/src/com/intellij/python/hatch/service/CliBasedHatchService.kt b/python/python-hatch/src/com/intellij/python/hatch/service/CliBasedHatchService.kt index 18daa8e110b6..7d79f86caf67 100644 --- a/python/python-hatch/src/com/intellij/python/hatch/service/CliBasedHatchService.kt +++ b/python/python-hatch/src/com/intellij/python/hatch/service/CliBasedHatchService.kt @@ -11,6 +11,7 @@ import com.intellij.python.hatch.* import com.intellij.python.hatch.cli.ENV_TYPE_VIRTUAL import com.intellij.python.hatch.cli.HatchEnvironment import com.intellij.python.hatch.cli.HatchEnvironments +import com.intellij.python.hatch.runtime.HatchConstants import com.intellij.python.hatch.runtime.HatchRuntime import com.intellij.python.hatch.runtime.createHatchRuntime import com.jetbrains.python.PythonBinary @@ -30,12 +31,14 @@ internal class CliBasedHatchService private constructor( private val hatchRuntime: HatchRuntime, ) : HatchService { companion object { - suspend operator fun invoke(workingDirectoryPath: Path, hatchExecutablePath: Path?): PyResult { + suspend operator fun invoke(workingDirectoryPath: Path?, hatchExecutablePath: Path? = null, hatchEnvironmentName: String? = null): PyResult { + val envVars = hatchEnvironmentName?.let { mapOf(HatchConstants.AppEnvVars.ENV to it) } ?: emptyMap() val hatchRuntime = createHatchRuntime( hatchExecutablePath = hatchExecutablePath, workingDirectoryPath = workingDirectoryPath, + envVars = envVars ).getOr { return it } - return Result.success(CliBasedHatchService(workingDirectoryPath, hatchRuntime)) + return Result.success(CliBasedHatchService(workingDirectoryPath!!, hatchRuntime)) } private val concurrencyLimit = Semaphore(permits = 5) @@ -51,7 +54,7 @@ internal class CliBasedHatchService private constructor( override fun getWorkingDirectoryPath(): Path = workingDirectoryPath - override suspend fun syncDependencies(envName: String): PyResult { + override suspend fun syncDependencies(envName: String?): PyResult { return withContext(Dispatchers.IO) { hatchRuntime.hatchCli().run(envName, "python", "--version") } diff --git a/python/src/com/jetbrains/python/ProcessExecutionErrorDialog.kt b/python/src/com/jetbrains/python/ProcessExecutionErrorDialog.kt index 4f7e6a53e16d..e571a0ee4763 100644 --- a/python/src/com/jetbrains/python/ProcessExecutionErrorDialog.kt +++ b/python/src/com/jetbrains/python/ProcessExecutionErrorDialog.kt @@ -98,7 +98,7 @@ fun showProcessExecutionErrorDialog( override fun createActions(): Array = arrayOf(okAction) override fun createCenterPanel(): JComponent = formBuilder.panel.apply { - preferredSize = Dimension(600, 300) + preferredSize = Dimension(820, 400) } }.showAndGet() } diff --git a/python/src/com/jetbrains/python/hatch/packaging/HatchPackageManager.kt b/python/src/com/jetbrains/python/hatch/packaging/HatchPackageManager.kt index d4c7e0d0b59d..4323abdf53a1 100644 --- a/python/src/com/jetbrains/python/hatch/packaging/HatchPackageManager.kt +++ b/python/src/com/jetbrains/python/hatch/packaging/HatchPackageManager.kt @@ -3,11 +3,15 @@ package com.jetbrains.python.hatch.packaging import com.intellij.openapi.project.Project import com.intellij.openapi.projectRoots.Sdk +import com.intellij.python.hatch.HatchService +import com.intellij.python.hatch.getHatchService +import com.jetbrains.python.errorProcessing.PyError import com.jetbrains.python.hatch.sdk.HatchSdkAdditionalData import com.jetbrains.python.hatch.sdk.isHatch import com.jetbrains.python.packaging.management.PythonPackageManager import com.jetbrains.python.packaging.management.PythonPackageManagerProvider import com.jetbrains.python.packaging.pip.PipPythonPackageManager +import com.jetbrains.python.Result internal class HatchPackageManager(project: Project, sdk: Sdk) : PipPythonPackageManager(project, sdk) { fun getSdkAdditionalData(): HatchSdkAdditionalData { @@ -16,6 +20,12 @@ internal class HatchPackageManager(project: Project, sdk: Sdk) : PipPythonPackag "additional data has to be ${HatchSdkAdditionalData::class.java.name}, " + "but was ${sdk.sdkAdditionalData?.javaClass?.name}") } + + suspend fun getHatchService(): Result { + val data = getSdkAdditionalData() + val workingDirectory = data.hatchWorkingDirectory + return workingDirectory.getHatchService(hatchEnvironmentName = data.hatchEnvironmentName) + } } internal class HatchPackageManagerProvider : PythonPackageManagerProvider { diff --git a/python/src/com/jetbrains/python/hatch/packaging/HatchPackageManagerAction.kt b/python/src/com/jetbrains/python/hatch/packaging/HatchPackageManagerAction.kt new file mode 100644 index 000000000000..c3c57ddd1f56 --- /dev/null +++ b/python/src/com/jetbrains/python/hatch/packaging/HatchPackageManagerAction.kt @@ -0,0 +1,24 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.jetbrains.python.hatch.packaging + +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.python.hatch.HATCH_TOML +import com.intellij.python.pyproject.PY_PROJECT_TOML +import com.jetbrains.python.Result +import com.jetbrains.python.errorProcessing.PyError +import com.jetbrains.python.packaging.management.PythonPackageManagerAction +import com.jetbrains.python.packaging.management.getPythonPackageManager +import kotlin.text.Regex.Companion.escape + +internal sealed class HatchPackageManagerAction : PythonPackageManagerAction() { + override val fileNamesPattern: Regex = """^(${escape(HATCH_TOML)}|${escape(PY_PROJECT_TOML)})$""".toRegex() + + override fun getManager(e: AnActionEvent): HatchPackageManager? = e.getPythonPackageManager() +} + +internal class HatchRunAction() : HatchPackageManagerAction() { + override suspend fun execute(e: AnActionEvent, manager: HatchPackageManager): Result { + val service = manager.getHatchService().getOr { return it } + return service.syncDependencies() + } +} diff --git a/python/src/com/jetbrains/python/packaging/management/PythonPackageManagerAction.kt b/python/src/com/jetbrains/python/packaging/management/PythonPackageManagerAction.kt new file mode 100644 index 000000000000..706bc5c29cb3 --- /dev/null +++ b/python/src/com/jetbrains/python/packaging/management/PythonPackageManagerAction.kt @@ -0,0 +1,153 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.jetbrains.python.packaging.management + +import com.intellij.codeInsight.daemon.DaemonCodeAnalyzer +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.openapi.application.readAction +import com.intellij.openapi.application.writeAction +import com.intellij.openapi.editor.Document +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.module.ModuleUtil +import com.intellij.openapi.project.DumbAwareAction +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.findPsiFile +import com.intellij.platform.ide.progress.withBackgroundProgress +import com.intellij.python.pyproject.PY_PROJECT_TOML +import com.jetbrains.python.PyBundle +import com.jetbrains.python.Result +import com.jetbrains.python.errorProcessing.ErrorSink +import com.jetbrains.python.errorProcessing.PyError +import com.jetbrains.python.onFailure +import com.jetbrains.python.onSuccess +import com.jetbrains.python.packaging.PyPackageManager +import com.jetbrains.python.sdk.PythonSdkUtil +import com.jetbrains.python.sdk.associatedModuleDir +import com.jetbrains.python.sdk.pythonSdk +import com.jetbrains.python.util.ShowingMessageErrorSync +import kotlinx.coroutines.Deferred +import kotlin.text.Regex.Companion.escape + +/** + * Abstract base class representing an action that interacts with a Python package manager to perform tool-specific operations on sdk. + * Such as installing, updating, or uninstalling Python packages, updating lock files, etc. + * + * @param T The type of the Python package manager this action operates on. + * @param V The result type of the background jobs performed by this action. + */ +abstract class PythonPackageManagerAction : DumbAwareAction() { + val errorSink: ErrorSink = ShowingMessageErrorSync + + /** + * The regex pattern that matches the file names that this action is applicable to. + */ + open val fileNamesPattern: Regex = """^${escape(PY_PROJECT_TOML)}$""".toRegex() + + /** + * Retrieves the manager instance associated with the given action event, see [AnActionEvent.getPythonPackageManager] + * + * @return the manager instance of type [T] associated with the action event, or null if there is no [T]-manager associated. + */ + protected abstract fun getManager(e: AnActionEvent): T? + + /** + * Executes the main logic of the action using the provided event and manager. + * + * @return [Result] which contains the successful result of type [V] or an error of type [PyError] if it fails. + */ + protected abstract suspend fun execute(e: AnActionEvent, manager: T): Result + + override fun update(e: AnActionEvent) { + val isWatchedFile = e.editor()?.virtualFile?.name?.let { fileNamesPattern.matches(it) } ?: false + val manager = if (isWatchedFile) getManager(e) else null + val lastExecutedJob = manager?.getLastExecutedJob() + + with(e.presentation) { + isVisible = manager != null + isEnabled = lastExecutedJob?.isActive != true + } + } + + /** + * Execution success callback, refreshes the environment and re-runs the inspection check. + * Might be overridden by subclasses. + */ + protected open suspend fun onSuccess(manager: T, document: Document?) { + withBackgroundProgress(manager.project, PyBundle.message("python.sdk.scanning.installed.packages")) { + manager.refreshEnvironment() + document?.reloadIntentions(manager.project) + } + } + + /** + * This action saves the current document on fs because tools are command line tools, and they need actual files to be up to date + * Handles errors via [errorSink] + */ + private fun T.executeActionJob(e: AnActionEvent): Deferred>? = runDeferredJob { + val document = e.editor()?.document + + document?.let { + writeAction { + FileDocumentManager.getInstance().saveDocument(document) + } + } + + withBackgroundProgress(project, e.presentation.text) { + execute(e, this@executeActionJob) + }.onSuccess { + onSuccess(this, document) + }.onFailure { + errorSink.emit(it) + } + } + + override fun actionPerformed(e: AnActionEvent) { + val manager = getManager(e) + manager?.executeActionJob(e) + } + + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT +} + +private fun AnActionEvent.editor(): Editor? = this.getData(CommonDataKeys.EDITOR) + +private fun Document.virtualFile() = FileDocumentManager.getInstance().getFile(this) + +private fun Editor.getPythonPackageManager(): PythonPackageManager? { + val virtualFile = this.document.virtualFile() ?: return null + val module = project?.let { ModuleUtil.findModuleForFile(virtualFile, it) } ?: return null + val manager = module.pythonSdk?.let { sdk -> + PythonPackageManager.forSdk(module.project, sdk) + } + return manager +} + +internal inline fun AnActionEvent.getPythonPackageManager(): T? { + return editor()?.getPythonPackageManager().let { it as? T } +} + +/** + * 1) Reloads package caches. + * 2) [PyPackageManager] is deprecated but its implementations still have their own package caches, so need to refresh them too. + * 3) some files likes uv.lock / poetry.lock might be added, so need to refresh module dir too. + */ +private suspend fun PythonPackageManager.refreshEnvironment() { + PythonSdkUtil.getSitePackagesDirectory(sdk)?.refresh(true, true) + sdk.associatedModuleDir?.refresh(true, false) + PyPackageManager.getInstance(sdk).refreshAndGetPackages(true) + reloadPackages() +} + +/** + * re-runs the inspection check using updated dependencies + */ +private suspend fun Document.reloadIntentions(project: Project) { + readAction { + val virtualFile = virtualFile() ?: return@readAction null + virtualFile.findPsiFile(project) + }?.let { psiFile -> + DaemonCodeAnalyzer.getInstance(project).restart(psiFile) + } +} diff --git a/python/src/com/jetbrains/python/packaging/management/PythonPackageManagerJobService.kt b/python/src/com/jetbrains/python/packaging/management/PythonPackageManagerJobService.kt new file mode 100644 index 000000000000..cd4454340229 --- /dev/null +++ b/python/src/com/jetbrains/python/packaging/management/PythonPackageManagerJobService.kt @@ -0,0 +1,86 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.jetbrains.python.packaging.management + +import com.intellij.ide.ActivityTracker +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.jetbrains.python.Result +import com.jetbrains.python.errorProcessing.PyError +import com.jetbrains.python.sdk.PythonSdkCoroutineService +import kotlinx.coroutines.* +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.ConcurrentMap +import kotlin.coroutines.CoroutineContext + +/** + * This service allows asynchronous and potentially long-running tasks tied to a specific [PythonPackageManager] instance (per SDK) + * to be executed safely, ensuring that only one job for a given manager is active at a time. + * Managers are command line tools, and they can't run commands in parallel. + * + * The service is thread-safe. + * + * **Note**: [PythonPackageManager] instances are considered equal if they refer to the same SDK. + * There is no equals/hashcode override for [PythonPackageManager] instances, + * so they are compared by reference only and we rely on [PythonPackageManagerServiceImpl.forSdk] / [PythonPackageManagerServiceImpl.cache] + * + * It also provides activity tracking signals to ensure proper UI updates. + */ +@Service(Service.Level.PROJECT) +internal class PythonPackageManagerJobService { + private val deferredJobs: ConcurrentMap>> = ConcurrentHashMap() + + fun getLastExecutedJob(manager: PythonPackageManager): Deferred>? { + return deferredJobs[manager] + } + + /** + * Runs a deferred job asynchronously for a given [PythonPackageManager] instance. The method ensures that only one + * job per manager can be active at a time. If a job is already active for the given manager, it returns null. + * Upon completion of the job, it performs a necessary cleanup and triggers update cycles for activity tracking. + * + * @param [scope] the `CoroutineScope` to be used for launching the deferred job. Default is the scope from [PythonSdkCoroutineService.cs]. + * @param [context] the `CoroutineContext` in which the coroutine is executed. Default is [Dispatchers.IO]. + * @param [runnable] a suspendable function that represents the operation to be performed, producing a [Result] with a success type [V] or a failure type [PyError]. + * @return a [Deferred] representing the asynchronous computation of the job, or null if a job is already active for the given manager. + */ + fun runDeferredJob( + manager: PythonPackageManager, + scope: CoroutineScope = service().cs, + context: CoroutineContext = Dispatchers.IO, + runnable: suspend () -> Result, + ): Deferred>? { + + val job = synchronized(deferredJobs) { + if (deferredJobs[manager]?.isActive == true) return null + + scope.async(context) { runnable.invoke() }.also { + deferredJobs[manager] = it + } + } + + job.invokeOnCompletion { exception -> + synchronized(deferredJobs) { deferredJobs.remove(manager) } // just a cleanup, this line might be removed if someone needs the last deferred job result in other places + ActivityTracker.getInstance().inc() // it forces the next update cycle to give all waiting/currently disabled actions a callback + } + + return job + } + + companion object { + fun getInstance(project: Project?): PythonPackageManagerJobService? = project?.service() + } +} + +internal fun PythonPackageManager.getLastExecutedJob(): Deferred>? { + return PythonPackageManagerJobService.getInstance(this.project)?.getLastExecutedJob(this) +} + +internal fun PythonPackageManager.runDeferredJob( + scope: CoroutineScope = service().cs, + dispatcher: CoroutineDispatcher = Dispatchers.IO, + runnable: suspend () -> Result, +): Deferred>? { + val jobService = PythonPackageManagerJobService.getInstance(this.project) ?: return null + return jobService.runDeferredJob(this, scope, dispatcher, runnable) +} diff --git a/python/src/com/jetbrains/python/poetry/package-info.java b/python/src/com/jetbrains/python/poetry/package-info.java new file mode 100644 index 000000000000..0118edb24f6b --- /dev/null +++ b/python/src/com/jetbrains/python/poetry/package-info.java @@ -0,0 +1,5 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +@ApiStatus.Internal +package com.jetbrains.python.poetry; + +import org.jetbrains.annotations.ApiStatus; \ No newline at end of file diff --git a/python/src/com/jetbrains/python/poetry/packaging/PoetryPackageManagerAction.kt b/python/src/com/jetbrains/python/poetry/packaging/PoetryPackageManagerAction.kt new file mode 100644 index 000000000000..0a05cc60d620 --- /dev/null +++ b/python/src/com/jetbrains/python/poetry/packaging/PoetryPackageManagerAction.kt @@ -0,0 +1,32 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.jetbrains.python.poetry.packaging + +import com.intellij.openapi.actionSystem.AnActionEvent +import com.jetbrains.python.Result +import com.jetbrains.python.errorProcessing.PyError +import com.jetbrains.python.errorProcessing.asPythonResult +import com.jetbrains.python.packaging.management.PythonPackageManagerAction +import com.jetbrains.python.packaging.management.getPythonPackageManager +import com.jetbrains.python.sdk.poetry.PoetryPackageManager +import com.jetbrains.python.sdk.poetry.runPoetryWithSdk + +internal sealed class PoetryPackageManagerAction : PythonPackageManagerAction() { + override fun getManager(e: AnActionEvent): PoetryPackageManager? = e.getPythonPackageManager() +} + +internal class PoetryLockAction() : PoetryPackageManagerAction() { + override suspend fun execute(e: AnActionEvent, manager: PoetryPackageManager): Result { + return runPoetryWithManager(manager, listOf("lock")) + } +} + +internal class PoetryUpdateAction() : PoetryPackageManagerAction() { + override suspend fun execute(e: AnActionEvent, manager: PoetryPackageManager): Result { + return runPoetryWithManager(manager, listOf("update")) + } +} + +private suspend fun runPoetryWithManager(manager: PoetryPackageManager, args: List): Result { + val result = runPoetryWithSdk(manager.sdk, *args.toTypedArray()) + return result.asPythonResult() +} \ No newline at end of file diff --git a/python/src/com/jetbrains/python/sdk/add/v2/poetry/EnvironmentCreatorPoetry.kt b/python/src/com/jetbrains/python/sdk/add/v2/poetry/EnvironmentCreatorPoetry.kt index c63ee77465de..cf34d57d5a5f 100644 --- a/python/src/com/jetbrains/python/sdk/add/v2/poetry/EnvironmentCreatorPoetry.kt +++ b/python/src/com/jetbrains/python/sdk/add/v2/poetry/EnvironmentCreatorPoetry.kt @@ -147,7 +147,7 @@ private class PoetryConfigService : SerializablePersistentStateComponent, @NlsSafe description: String) { - service().cs.launch { - withBackgroundProgress(module.project, "$description...", true) { - val sdk = module.pythonSdk ?: return@withBackgroundProgress - try { - val result = runPoetryWithSdk(sdk, *args.toTypedArray()).exceptionOrNull() - if (result is ExecutionException) { - withContext(Dispatchers.EDT) { - showSdkExecutionException(sdk, result, PyBundle.message("sdk.create.custom.venv.run.error.message", "poetry")) - } - } - } - finally { - PythonSdkUtil.getSitePackagesDirectory(sdk)?.refresh(true, true) - sdk.associatedModuleDir?.refresh(true, false) - PythonPackageManager.forSdk(module.project, sdk).reloadPackages() - PyPackageManager.getInstance(sdk).refreshAndGetPackages(true) - } - } - } -} - internal suspend fun detectPoetryEnvs(module: Module?, existingSdkPaths: Set?, projectPath: @SystemIndependent @NonNls String?): List { val path = module?.basePath?.let { Path.of(it) } ?: projectPath?.let { Path.of(it) } ?: return emptyList() return getPoetryEnvs(path).filter { existingSdkPaths?.contains(getPythonExecutable(it)) != false }.map { PyDetectedSdk(getPythonExecutable(it)) } diff --git a/python/src/com/jetbrains/python/sdk/poetry/PoetryFilesUtils.kt b/python/src/com/jetbrains/python/sdk/poetry/PoetryFilesUtils.kt index 54caf83c2225..40b59b09d18a 100644 --- a/python/src/com/jetbrains/python/sdk/poetry/PoetryFilesUtils.kt +++ b/python/src/com/jetbrains/python/sdk/poetry/PoetryFilesUtils.kt @@ -64,7 +64,7 @@ suspend fun getPyProjectTomlForPoetry(virtualFile: VirtualFile): VirtualFile? = * The PyProject.toml found in the main content root of the module. */ @Internal -suspend fun pyProjectToml(module: Module): VirtualFile? = withContext(Dispatchers.IO) { findAmongRoots(module, PY_PROJECT_TOML) } +suspend fun findPyProjectToml(module: Module): VirtualFile? = withContext(Dispatchers.IO) { findAmongRoots(module, PY_PROJECT_TOML) } internal suspend fun poetryToml(module: Module): VirtualFile? = withContext(Dispatchers.IO) { findAmongRoots(module, POETRY_TOML)?.takeIf { readAction { ProjectFileIndex.getInstance(module.project).isInProject(it) } } diff --git a/python/src/com/jetbrains/python/sdk/poetry/PoetryPyProjectTomlWatcher.kt b/python/src/com/jetbrains/python/sdk/poetry/PoetryPyProjectTomlWatcher.kt deleted file mode 100644 index 9381e5d40c21..000000000000 --- a/python/src/com/jetbrains/python/sdk/poetry/PoetryPyProjectTomlWatcher.kt +++ /dev/null @@ -1,151 +0,0 @@ -// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -package com.jetbrains.python.sdk.poetry - -import com.intellij.notification.NotificationGroupManager -import com.intellij.notification.NotificationListener -import com.intellij.notification.NotificationType -import com.intellij.openapi.components.service -import com.intellij.openapi.editor.Document -import com.intellij.openapi.editor.event.DocumentEvent -import com.intellij.openapi.editor.event.DocumentListener -import com.intellij.openapi.editor.event.EditorFactoryEvent -import com.intellij.openapi.editor.event.EditorFactoryListener -import com.intellij.openapi.fileEditor.FileDocumentManager -import com.intellij.openapi.module.Module -import com.intellij.openapi.module.ModuleUtil -import com.intellij.openapi.project.Project -import com.intellij.openapi.util.Key -import com.intellij.openapi.util.NlsContexts.NotificationContent -import com.intellij.openapi.vfs.VirtualFile -import com.jetbrains.python.PyBundle -import com.jetbrains.python.sdk.PythonSdkCoroutineService -import com.jetbrains.python.sdk.findAmongRoots -import com.jetbrains.python.sdk.pythonSdk -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext -import org.jetbrains.annotations.Nls - -private class PoetryProjectTomlListener(val module: Module) : DocumentListener { - private val LOCK_NOTIFICATION_GROUP by lazy { NotificationGroupManager.getInstance().getNotificationGroup("pyproject.toml Watcher") } - private val notificationActive = Key.create("PyProjectToml.notification.active") - private val documentChangedMutex = Mutex() - - override fun documentChanged(event: DocumentEvent) { - service().cs.launch { - if (!FileDocumentManager.getInstance().isDocumentUnsaved(event.document)) return@launch - - documentChangedMutex.withLock(module) { - if (isNotificationActive()) return@launch - setNotificationActive(true) - } - - notifyPyProjectTomlChanged(module) - } - } - - @NotificationContent - private suspend fun content(): @Nls String = if (getPoetryVersion()?.let { it < "1.1.1" } == true) { - PyBundle.message("python.sdk.poetry.pip.file.notification.content") - } - else { - PyBundle.message("python.sdk.poetry.pip.file.notification.content.without.updating") - } - - private suspend fun poetryLock(module: Module) = withContext(Dispatchers.IO) { findAmongRoots(module, POETRY_LOCK) } - - fun isNotificationActive(): Boolean = module.getUserData(notificationActive) == true - - fun setNotificationActive(isActive: Boolean): Unit = module.putUserData(notificationActive, isActive.takeIf { it }) - - private suspend fun notifyPyProjectTomlChanged(module: Module) { - @Suppress("DialogTitleCapitalization") val title = when (poetryLock(module)) { - null -> PyBundle.message("python.sdk.poetry.pip.file.lock.not.found") - else -> PyBundle.message("python.sdk.poetry.pip.file.lock.out.of.date") - } - - val notification = LOCK_NOTIFICATION_GROUP.createNotification(title, content(), NotificationType.INFORMATION).setListener( - NotificationListener { notification, event -> - FileDocumentManager.getInstance().saveAllDocuments() - when (event.description) { - "#lock" -> - runPoetryInBackground(module, listOf("lock"), PyBundle.message("python.sdk.poetry.pip.file.notification.locking")) - "#noupdate" -> - runPoetryInBackground(module, listOf("lock", "--no-update"), - PyBundle.message("python.sdk.poetry.pip.file.notification.locking.without.updating")) - "#update" -> - runPoetryInBackground(module, listOf("update"), PyBundle.message("python.sdk.poetry.pip.file.notification.updating")) - } - notification.expire() - }) - - notification.whenExpired { - service().cs.launch { - setNotificationActive(false) - } - } - - notification.notify(module.project) - } -} - -/** - * Watches for edits in PyProjectToml inside modules with a poetry SDK set. - */ -internal class PoetryPyProjectTomlWatcher : EditorFactoryListener { - private val changeListenerKey = Key.create("Poetry.PyProjectToml.change.listener") - - @OptIn(ExperimentalCoroutinesApi::class) - private val queueDispatcher = Dispatchers.Default.limitedParallelism(1, "PoetryPyProjectTomlWatcher Queue Dispatcher") - - - private fun Document.addPoetryListener(module: Module) = synchronized(changeListenerKey) { - getUserData(changeListenerKey)?.let { return@addPoetryListener } - - PoetryProjectTomlListener(module).let { - addDocumentListener(it) - putUserData(changeListenerKey, it) - } - } - - private fun Document.removePoetryListenerIfExists() = synchronized(changeListenerKey) { - getUserData(changeListenerKey)?.let { listener -> - removeDocumentListener(listener) - putUserData(changeListenerKey, null) - } - } - - private fun queuedLaunch(block: suspend () -> Unit) { - service().cs.launch { - withContext(queueDispatcher) { - block() - } - } - } - - override fun editorCreated(event: EditorFactoryEvent) = queuedLaunch { - val project = event.editor.project ?: return@queuedLaunch - - val editablePyProjectTomlFile = event.editor.document.virtualFile?.takeIf { it.name == PY_PROJECT_TOML } ?: return@queuedLaunch - val module = getModule(editablePyProjectTomlFile, project) ?: return@queuedLaunch - val poetryManagedTomlFile = module.takeIf { it.pythonSdk?.isPoetry == true }?.let { pyProjectToml(it) } - - if (editablePyProjectTomlFile == poetryManagedTomlFile) { - event.editor.document.addPoetryListener(module) - } - } - - override fun editorReleased(event: EditorFactoryEvent) = queuedLaunch { - event.editor.document.removePoetryListenerIfExists() - } - - private val Document.virtualFile: VirtualFile? - get() = FileDocumentManager.getInstance().getFile(this) - - private suspend fun getModule(file: VirtualFile, project: Project): Module? = withContext(Dispatchers.IO) { - ModuleUtil.findModuleForFile(file, project) - } -} \ No newline at end of file diff --git a/python/src/com/jetbrains/python/sdk/poetry/ui/PyAddNewPoetryPanel.kt b/python/src/com/jetbrains/python/sdk/poetry/ui/PyAddNewPoetryPanel.kt index de83d4546fa0..e0e93db25b5c 100644 --- a/python/src/com/jetbrains/python/sdk/poetry/ui/PyAddNewPoetryPanel.kt +++ b/python/src/com/jetbrains/python/sdk/poetry/ui/PyAddNewPoetryPanel.kt @@ -169,7 +169,7 @@ class PyAddNewPoetryPanel( private fun update() { service().cs.launch { selectedModule?.let { - installPackagesCheckBox.isEnabled = pyProjectToml(it) != null + installPackagesCheckBox.isEnabled = findPyProjectToml(it) != null } } } diff --git a/python/src/com/jetbrains/python/sdk/uv/Uv.kt b/python/src/com/jetbrains/python/sdk/uv/Uv.kt index 5b048dc82b82..d57d8ca0048f 100644 --- a/python/src/com/jetbrains/python/sdk/uv/Uv.kt +++ b/python/src/com/jetbrains/python/sdk/uv/Uv.kt @@ -29,4 +29,7 @@ interface UvLowLevel { suspend fun listPackages(): Result> suspend fun listOutdatedPackages(): Result> + + suspend fun sync(): Result + suspend fun lock(): Result } \ No newline at end of file diff --git a/python/src/com/jetbrains/python/sdk/uv/UvPackageManager.kt b/python/src/com/jetbrains/python/sdk/uv/UvPackageManager.kt index fbc3faa2477d..b6d75acdab90 100644 --- a/python/src/com/jetbrains/python/sdk/uv/UvPackageManager.kt +++ b/python/src/com/jetbrains/python/sdk/uv/UvPackageManager.kt @@ -67,6 +67,14 @@ internal class UvPackageManager(project: Project, sdk: Sdk, private val uv: UvLo return uv.listPackages() } + + suspend fun sync(): Result { + return uv.sync() + } + + suspend fun lock(): Result { + return uv.lock() + } } class UvPackageManagerProvider : PythonPackageManagerProvider { diff --git a/python/src/com/jetbrains/python/sdk/uv/impl/UvLowLevel.kt b/python/src/com/jetbrains/python/sdk/uv/impl/UvLowLevel.kt index 934a58077291..339ef09bf496 100644 --- a/python/src/com/jetbrains/python/sdk/uv/impl/UvLowLevel.kt +++ b/python/src/com/jetbrains/python/sdk/uv/impl/UvLowLevel.kt @@ -172,6 +172,14 @@ private class UvLowLevelImpl(val cwd: Path, private val uvCli: UvCli) : UvLowLev return pythons } + + override suspend fun sync(): Result { + return uvCli.runUv(cwd, "sync") + } + + override suspend fun lock(): Result { + return uvCli.runUv(cwd, "lock") + } } fun createUvLowLevel(cwd: Path, uvCli: UvCli = createUvCli()): UvLowLevel { diff --git a/python/src/com/jetbrains/python/uv/package-info.java b/python/src/com/jetbrains/python/uv/package-info.java new file mode 100644 index 000000000000..2185fd666c2f --- /dev/null +++ b/python/src/com/jetbrains/python/uv/package-info.java @@ -0,0 +1,5 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +@ApiStatus.Internal +package com.jetbrains.python.uv; + +import org.jetbrains.annotations.ApiStatus; \ No newline at end of file diff --git a/python/src/com/jetbrains/python/uv/packaging/UvPackageManagerAction.kt b/python/src/com/jetbrains/python/uv/packaging/UvPackageManagerAction.kt new file mode 100644 index 000000000000..886f1b28e0c2 --- /dev/null +++ b/python/src/com/jetbrains/python/uv/packaging/UvPackageManagerAction.kt @@ -0,0 +1,26 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.jetbrains.python.uv.packaging + +import com.intellij.openapi.actionSystem.AnActionEvent +import com.jetbrains.python.Result +import com.jetbrains.python.errorProcessing.PyError +import com.jetbrains.python.errorProcessing.asPythonResult +import com.jetbrains.python.packaging.management.PythonPackageManagerAction +import com.jetbrains.python.packaging.management.getPythonPackageManager +import com.jetbrains.python.sdk.uv.UvPackageManager + +internal sealed class UvPackageManagerAction : PythonPackageManagerAction() { + override fun getManager(e: AnActionEvent): UvPackageManager? = e.getPythonPackageManager() +} + +internal class UvSyncAction() : UvPackageManagerAction() { + override suspend fun execute(e: AnActionEvent, manager: UvPackageManager): Result { + return manager.sync().asPythonResult() + } +} + +internal class UvLockAction() : UvPackageManagerAction() { + override suspend fun execute(e: AnActionEvent, manager: UvPackageManager): Result { + return manager.lock().asPythonResult() + } +} \ No newline at end of file