From c5ca662b4b950456b3ef3b2355f8a7573174fe5d Mon Sep 17 00:00:00 2001 From: Vitaly Legchilkin Date: Thu, 8 May 2025 12:41:11 +0200 Subject: [PATCH] [python] implement python package manager actions (PY-79451) * PythonPackageManagerJobService.kt added to manage tool jobs * Base PythonPackageManagerAction.kt was added to cover all python package manager actions * Implementations for Poetry / Hatch / uv * Poetry pyproject.toml watcher was removed (replaced with poetry actions) (cherry picked from commit 0bbc5a7802826674140ca1c80be27b6cd7d0f59e) GitOrigin-RevId: d3b6486ca9a24ecd7188e8c5308fb38aae5ed318 --- .../pluginCore/resources/META-INF/plugin.xml | 37 +++- .../messages/PyBundle.properties | 9 +- .../com/intellij/python/hatch/cli/HatchCli.kt | 19 +- .../src/com/intellij/python/hatch/hatch.kt | 8 +- .../hatch/service/CliBasedHatchService.kt | 9 +- .../python/ProcessExecutionErrorDialog.kt | 2 +- .../hatch/packaging/HatchPackageManager.kt | 10 + .../packaging/HatchPackageManagerAction.kt | 24 +++ .../management/CancellableJobSerialRunner.kt | 63 +++++++ .../management/PythonPackageManagerAction.kt | 178 ++++++++++++++++++ .../jetbrains/python/poetry/package-info.java | 5 + .../packaging/PoetryPackageManagerAction.kt | 32 ++++ .../add/v2/poetry/EnvironmentCreatorPoetry.kt | 4 +- .../PoetryExistingEnvironmentSelector.kt | 4 +- .../sdk/poetry/PoetryCommandExecutor.kt | 30 --- .../python/sdk/poetry/PoetryFilesUtils.kt | 2 +- .../sdk/poetry/PoetryPyProjectTomlWatcher.kt | 152 --------------- .../sdk/poetry/ui/PyAddNewPoetryPanel.kt | 2 +- python/src/com/jetbrains/python/sdk/uv/Uv.kt | 3 + .../python/sdk/uv/UvPackageManager.kt | 8 + .../python/sdk/uv/impl/UvLowLevel.kt | 8 + .../com/jetbrains/python/uv/package-info.java | 5 + .../uv/packaging/UvPackageManagerAction.kt | 26 +++ 23 files changed, 427 insertions(+), 213 deletions(-) create mode 100644 python/src/com/jetbrains/python/hatch/packaging/HatchPackageManagerAction.kt create mode 100644 python/src/com/jetbrains/python/packaging/management/CancellableJobSerialRunner.kt create mode 100644 python/src/com/jetbrains/python/packaging/management/PythonPackageManagerAction.kt create mode 100644 python/src/com/jetbrains/python/poetry/package-info.java create mode 100644 python/src/com/jetbrains/python/poetry/packaging/PoetryPackageManagerAction.kt delete mode 100644 python/src/com/jetbrains/python/sdk/poetry/PoetryPyProjectTomlWatcher.kt create mode 100644 python/src/com/jetbrains/python/uv/package-info.java create mode 100644 python/src/com/jetbrains/python/uv/packaging/UvPackageManagerAction.kt diff --git a/python/pluginCore/resources/META-INF/plugin.xml b/python/pluginCore/resources/META-INF/plugin.xml index 39bc1ac3b723..1ddd800dc502 100644 --- a/python/pluginCore/resources/META-INF/plugin.xml +++ b/python/pluginCore/resources/META-INF/plugin.xml @@ -572,9 +572,6 @@ 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 07d69576d0c5..849e5d56a8fb 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 @@ -1600,6 +1597,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 0e8d0ae5c230..53d38c5a7840 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 74c7636a22ac..a8c976fce169 100644 --- a/python/python-hatch/src/com/intellij/python/hatch/hatch.kt +++ b/python/python-hatch/src/com/intellij/python/hatch/hatch.kt @@ -14,6 +14,8 @@ import com.jetbrains.python.errorProcessing.PyError import com.jetbrains.python.sdk.basePath import java.nio.file.Path +const val HATCH_TOML: String = "hatch.toml" + sealed class HatchError(message: @NlsSafe String) : PyError.Message(message) class HatchExecutableNotFoundHatchError(path: Path?) : HatchError( @@ -79,7 +81,7 @@ data class ProjectStructure( interface HatchService { fun getWorkingDirectoryPath(): Path - suspend fun syncDependencies(envName: String): Result + suspend fun syncDependencies(envName: String? = null): Result suspend fun isHatchManagedProject(): Result @@ -97,8 +99,8 @@ interface HatchService { /** * Hatch Service for working directory (where hatch.toml / pyproject.toml is usually placed) */ -suspend fun Path.getHatchService(hatchExecutablePath: Path? = null): Result { - return CliBasedHatchService(hatchExecutablePath = hatchExecutablePath, workingDirectoryPath = this) +suspend fun Path?.getHatchService(hatchExecutablePath: Path? = null, hatchEnvironmentName: String? = null): Result { + 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 eebad0acf14a..6708489ef24e 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 @@ -10,6 +10,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 @@ -29,12 +30,14 @@ internal class CliBasedHatchService private constructor( private val hatchRuntime: HatchRuntime, ) : HatchService { companion object { - suspend operator fun invoke(workingDirectoryPath: Path, hatchExecutablePath: Path?): Result { + suspend operator fun invoke(workingDirectoryPath: Path?, hatchExecutablePath: Path? = null, hatchEnvironmentName: String? = null): Result { + 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) @@ -50,7 +53,7 @@ internal class CliBasedHatchService private constructor( override fun getWorkingDirectoryPath(): Path = workingDirectoryPath - override suspend fun syncDependencies(envName: String): Result { + override suspend fun syncDependencies(envName: String?): Result { 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 e40854043e87..8c5d41a19877 100644 --- a/python/src/com/jetbrains/python/ProcessExecutionErrorDialog.kt +++ b/python/src/com/jetbrains/python/ProcessExecutionErrorDialog.kt @@ -70,7 +70,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/CancellableJobSerialRunner.kt b/python/src/com/jetbrains/python/packaging/management/CancellableJobSerialRunner.kt new file mode 100644 index 000000000000..d07243099035 --- /dev/null +++ b/python/src/com/jetbrains/python/packaging/management/CancellableJobSerialRunner.kt @@ -0,0 +1,63 @@ +// 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.project.Project +import com.intellij.openapi.util.Key +import com.intellij.openapi.util.NlsContexts.ProgressTitle +import com.intellij.openapi.util.UserDataHolder +import com.intellij.openapi.util.getOrCreateUserDataUnsafe +import com.intellij.platform.ide.progress.withBackgroundProgress +import com.jetbrains.python.errorProcessing.PyError +import com.jetbrains.python.Result +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +private val MUTEX_KEY = Key.create("${CancellableJobSerialRunner::class.java.name}.mutex") + +/** + * This service allows asynchronous and potentially long-running jobs tied to a specific [UserDataHolder] instance + * to be executed safely, ensuring that only one job for a given object is active at a time. + * + * The service is thread-safe. + * + * It also provides activity tracking signals to ensure proper UI updates. + */ +internal object CancellableJobSerialRunner { + + private fun getMutex(holder: UserDataHolder): Mutex { + return synchronized(holder) { + holder.getOrCreateUserDataUnsafe(MUTEX_KEY) { Mutex() } + } + } + + fun isRunLocked(holder: UserDataHolder): Boolean = getMutex(holder).isLocked + + /** + * Runs a [runnable] job synchronized on a given [UserDataHolder] instance. + * The method ensures that only one runnable per holder can be active at a time. + * It always creates cancellable background progress for each job so the job queue might be managed from the UI. + * If an active job already exists for the given holder, the coroutine suspends until it can acquire the holder's mutex. + * Upon completion of the job, it triggers activity tracking to refresh UI components visibility. + * + * @param [runnable] a suspendable function that represents the job to be performed, + * producing a [PyResult] with a success type [V] or a failure type [PyError]. + */ + suspend fun run( + project: Project, + holder: UserDataHolder, + title: @ProgressTitle String, + runnable: suspend () -> Result, + ): Result { + + val mutex = getMutex(holder) + + return withBackgroundProgress(project, title, cancellable = true) { + mutex.withLock { + runnable.invoke() + }.also { + ActivityTracker.getInstance().inc() // it forces the next update cycle to give all waiting/currently disabled actions a callback + } + } + } +} 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..30f561070edd --- /dev/null +++ b/python/src/com/jetbrains/python/packaging/management/PythonPackageManagerAction.kt @@ -0,0 +1,178 @@ +// 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.runInEdt +import com.intellij.openapi.components.service +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.util.NlsContexts.ProgressTitle +import com.intellij.openapi.vfs.findPsiFile +import com.intellij.platform.util.progress.reportSequentialProgress +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.PythonSdkCoroutineService +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.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlin.coroutines.CoroutineContext +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() { + protected val errorSink: ErrorSink = ShowingMessageErrorSync + protected val scope: CoroutineScope = service().cs + protected val context: CoroutineContext = Dispatchers.IO + + /** + * The regex pattern that matches the file names that this action is applicable to. + */ + protected 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 + + with(e.presentation) { + isVisible = manager != null + isEnabled = manager?.isRunLocked() == false + } + } + + /** + * Execution success callback, refreshes the environment and re-runs the inspection check. + * Might be overridden by subclasses. + */ + private suspend fun onSuccess(manager: T, document: Document?) { + manager.refreshEnvironment() + document?.reloadIntentions(manager.project) + } + + private suspend fun executeScenarioWithinProgress(manager: T, e: AnActionEvent, document: Document?): Result { + return reportSequentialProgress(2) { reporter -> + reporter.itemStep { + execute(e, manager) + }.onSuccess { + reporter.itemStep(PyBundle.message("python.sdk.scanning.installed.packages")) { + onSuccess(manager, document) + } + }.onFailure { + errorSink.emit(it) + } + } + } + + /** + * 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] + */ + override fun actionPerformed(e: AnActionEvent) { + val manager = getManager(e) ?: return + val document = e.editor()?.document + + document?.let { + runInEdt { + FileDocumentManager.getInstance().saveDocument(document) + } + } + + scope.launch(context) { + manager.runSynchronized(e.presentation.text) { + executeScenarioWithinProgress(manager, e, document) + } + } + } + + 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() 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) + } +} + + +internal fun PythonPackageManager.isRunLocked(): Boolean { + return CancellableJobSerialRunner.isRunLocked(this.sdk) +} + +internal suspend fun PythonPackageManager.runSynchronized( + title: @ProgressTitle String, + runnable: suspend () -> Result, +): Result { + return CancellableJobSerialRunner.run(this.project, this.sdk, title, 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 52f92b300c58..5e619d59eb79 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 @@ -30,11 +30,9 @@ import com.jetbrains.python.errorProcessing.PyError import com.jetbrains.python.errorProcessing.asPythonResult import com.jetbrains.python.newProjectWizard.collector.PythonNewProjectWizardCollector import com.jetbrains.python.sdk.add.v2.CustomNewEnvironmentCreator -import com.jetbrains.python.sdk.add.v2.PythonInterpreterSelectionMethod import com.jetbrains.python.sdk.add.v2.PythonInterpreterSelectionMethod.* import com.jetbrains.python.sdk.add.v2.PythonMutableTargetAddInterpreterModel import com.jetbrains.python.sdk.add.v2.PythonSelectableInterpreter -import com.jetbrains.python.sdk.add.v2.PythonSupportedEnvironmentManagers import com.jetbrains.python.sdk.add.v2.PythonSupportedEnvironmentManagers.* import com.jetbrains.python.sdk.add.v2.VenvExistenceValidationState.* import kotlinx.coroutines.flow.MutableStateFlow @@ -154,7 +152,7 @@ internal class PoetryConfigService : PersistentStateComponent, @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 27c6ff119dcf..000000000000 --- a/python/src/com/jetbrains/python/sdk/poetry/PoetryPyProjectTomlWatcher.kt +++ /dev/null @@ -1,152 +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.intellij.platform.util.coroutines.limitedParallelism -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 9dbf86dd789f..b23b56716fa7 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