[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)

GitOrigin-RevId: 0bbc5a7802826674140ca1c80be27b6cd7d0f59e
This commit is contained in:
Vitaly Legchilkin
2025-05-08 01:15:49 +00:00
committed by intellij-monorepo-bot
parent 57d61a67e1
commit 91a13e2436
23 changed files with 428 additions and 208 deletions
@@ -1,5 +1,5 @@
<!--suppress PluginXmlRegistrationCheck -->
<idea-plugin xmlns:xi="http://www.w3.org/2001/XInclude" url="https://www.jetbrains.com/pycharm/">
<idea-plugin url="https://www.jetbrains.com/pycharm/">
<!--Python community support for Pycharm, DS and IC.-->
<!--Include core Python functionality-->
@@ -69,4 +69,40 @@ The Python plug-in provides smart editing for Python scripts. The feature set of
<module name="intellij.python.terminal"/>
</content>
<actions>
<group id="PythonPackageManagerActions" searchable="false">
<separator/>
<action id="UvLockAction"
class="com.jetbrains.python.uv.packaging.UvLockAction"
icon="com.intellij.icons.AllIcons.Diff.Lock"/>
<action id="UvSyncAction"
class="com.jetbrains.python.uv.packaging.UvSyncAction"
icon="com.intellij.icons.AllIcons.Actions.Refresh"/>
<separator/>
<action id="PoetryLockAction"
class="com.jetbrains.python.poetry.packaging.PoetryLockAction"
icon="com.intellij.icons.AllIcons.Diff.Lock"/>
<action id="PoetryUpdateAction"
class="com.jetbrains.python.poetry.packaging.PoetryUpdateAction"
icon="com.intellij.icons.AllIcons.Actions.Refresh"/>
<separator/>
<action id="HatchRunAction"
class="com.jetbrains.python.hatch.packaging.HatchRunAction"
icon="com.intellij.icons.AllIcons.Actions.Refresh"/>
<separator/>
<add-to-group group-id="EditorContextBarMenu" anchor="last"/>
<!--<add-to-group group-id="EditorPopupMenu" relative-to-action="ShowIntentionsGroup" anchor="after"/>-->
</group>
</actions>
</idea-plugin>
@@ -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 <a href='#lock'>poetry lock</a> or <a href='#update'>poetry update</a>
python.sdk.poetry.pip.file.notification.content.without.updating=Run <a href='#lock'>poetry lock</a>, <a href='#noupdate'>poetry lock --no-update</a> or <a href='#update'>poetry update</a>
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
@@ -146,19 +146,16 @@ class HatchCli(private val runtime: HatchRuntime) {
/**
* Run commands within project environments
*/
suspend fun run(envName: String, vararg command: String): Result<String, ExecError> {
val envRuntime = runtime.withEnv(HatchConstants.AppEnvVars.ENV to envName)
suspend fun run(envName: String? = null, vararg command: String): Result<String, ExecError> {
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")
}
}
@@ -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<String>
suspend fun syncDependencies(envName: String? = null): PyResult<String>
suspend fun isHatchManagedProject(): PyResult<Boolean>
@@ -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<HatchService> {
return CliBasedHatchService(hatchExecutablePath = hatchExecutablePath, workingDirectoryPath = this)
suspend fun Path?.getHatchService(hatchExecutablePath: Path? = null, hatchEnvironmentName: String? = null): PyResult<HatchService> {
return CliBasedHatchService(hatchExecutablePath = hatchExecutablePath, workingDirectoryPath = this, hatchEnvironmentName = hatchEnvironmentName)
}
/**
@@ -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<CliBasedHatchService> {
suspend operator fun invoke(workingDirectoryPath: Path?, hatchExecutablePath: Path? = null, hatchEnvironmentName: String? = null): PyResult<CliBasedHatchService> {
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<String> {
override suspend fun syncDependencies(envName: String?): PyResult<String> {
return withContext(Dispatchers.IO) {
hatchRuntime.hatchCli().run(envName, "python", "--version")
}
@@ -98,7 +98,7 @@ fun showProcessExecutionErrorDialog(
override fun createActions(): Array<Action> = arrayOf(okAction)
override fun createCenterPanel(): JComponent = formBuilder.panel.apply {
preferredSize = Dimension(600, 300)
preferredSize = Dimension(820, 400)
}
}.showAndGet()
}
@@ -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<HatchService, PyError> {
val data = getSdkAdditionalData()
val workingDirectory = data.hatchWorkingDirectory
return workingDirectory.getHatchService(hatchEnvironmentName = data.hatchEnvironmentName)
}
}
internal class HatchPackageManagerProvider : PythonPackageManagerProvider {
@@ -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<HatchPackageManager, String>() {
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<String, PyError> {
val service = manager.getHatchService().getOr { return it }
return service.syncDependencies()
}
}
@@ -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<T : PythonPackageManager, V> : 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<V, PyError>
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<Result<V, PyError>>? = 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 <reified T : PythonPackageManager> 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)
}
}
@@ -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<PythonPackageManager, Deferred<Result<*, PyError>>> = ConcurrentHashMap()
fun getLastExecutedJob(manager: PythonPackageManager): Deferred<Result<*, PyError>>? {
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 <V> runDeferredJob(
manager: PythonPackageManager,
scope: CoroutineScope = service<PythonSdkCoroutineService>().cs,
context: CoroutineContext = Dispatchers.IO,
runnable: suspend () -> Result<V, PyError>,
): Deferred<Result<V, PyError>>? {
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<Result<*, PyError>>? {
return PythonPackageManagerJobService.getInstance(this.project)?.getLastExecutedJob(this)
}
internal fun <V> PythonPackageManager.runDeferredJob(
scope: CoroutineScope = service<PythonSdkCoroutineService>().cs,
dispatcher: CoroutineDispatcher = Dispatchers.IO,
runnable: suspend () -> Result<V, PyError>,
): Deferred<Result<V, PyError>>? {
val jobService = PythonPackageManagerJobService.getInstance(this.project) ?: return null
return jobService.runDeferredJob(this, scope, dispatcher, runnable)
}
@@ -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;
@@ -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<PoetryPackageManager, String>() {
override fun getManager(e: AnActionEvent): PoetryPackageManager? = e.getPythonPackageManager()
}
internal class PoetryLockAction() : PoetryPackageManagerAction() {
override suspend fun execute(e: AnActionEvent, manager: PoetryPackageManager): Result<String, PyError> {
return runPoetryWithManager(manager, listOf("lock"))
}
}
internal class PoetryUpdateAction() : PoetryPackageManagerAction() {
override suspend fun execute(e: AnActionEvent, manager: PoetryPackageManager): Result<String, PyError> {
return runPoetryWithManager(manager, listOf("update"))
}
}
private suspend fun runPoetryWithManager(manager: PoetryPackageManager, args: List<String>): Result<String, PyError> {
val result = runPoetryWithSdk(manager.sdk, *args.toTypedArray())
return result.asPythonResult()
}
@@ -147,7 +147,7 @@ private class PoetryConfigService : SerializablePersistentStateComponent<PoetryC
suspend fun setInProjectEnv(module: Module) {
val hasPoetryToml = poetryToml(module) != null
if (state.isInProjectEnv || hasPoetryToml) {
val modulePath = withContext(Dispatchers.IO) { pyProjectToml(module)?.parent?.toNioPath() ?: module.basePath?.let { Path.of(it) } }
val modulePath = withContext(Dispatchers.IO) { findPyProjectToml(module)?.parent?.toNioPath() ?: module.basePath?.let { Path.of(it) } }
configurePoetryEnvironment(modulePath, "virtualenvs.in-project", state.isInProjectEnv.toString(), "--local")
}
}
@@ -15,7 +15,7 @@ import com.jetbrains.python.sdk.add.v2.DetectedSelectableInterpreter
import com.jetbrains.python.sdk.add.v2.PythonMutableTargetAddInterpreterModel
import com.jetbrains.python.sdk.poetry.detectPoetryEnvs
import com.jetbrains.python.sdk.poetry.isPoetry
import com.jetbrains.python.sdk.poetry.pyProjectToml
import com.jetbrains.python.sdk.poetry.findPyProjectToml
import com.jetbrains.python.sdk.poetry.setupPoetrySdkUnderProgress
import com.jetbrains.python.statistics.InterpreterType
import com.jetbrains.python.statistics.version
@@ -47,5 +47,5 @@ internal class PoetryExistingEnvironmentSelector(model: PythonMutableTargetAddIn
existingEnvironments.value = existingEnvs
}
override suspend fun findModulePath(module: Module): Path? = pyProjectToml(module)?.toNioPathOrNull()?.parent
override suspend fun findModulePath(module: Module): Path? = findPyProjectToml(module)?.toNioPathOrNull()?.parent
}
@@ -1,19 +1,14 @@
// 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.execution.ExecutionException
import com.intellij.execution.configurations.PathEnvironmentVariableUtil
import com.intellij.ide.util.PropertiesComponent
import com.intellij.openapi.application.EDT
import com.intellij.openapi.components.service
import com.intellij.openapi.module.Module
import com.intellij.openapi.projectRoots.Sdk
import com.intellij.openapi.ui.ValidationInfo
import com.intellij.openapi.util.NlsSafe
import com.intellij.openapi.util.SystemInfo
import com.intellij.openapi.util.io.FileUtil
import com.intellij.openapi.util.registry.Registry
import com.intellij.platform.ide.progress.withBackgroundProgress
import com.intellij.platform.util.progress.reportSequentialProgress
import com.intellij.python.community.execService.ExecOptions
import com.intellij.python.community.execService.ExecService
@@ -23,12 +18,10 @@ import com.intellij.util.SystemProperties
import com.jetbrains.python.PyBundle
import com.jetbrains.python.onFailure
import com.jetbrains.python.packaging.PyPackage
import com.jetbrains.python.packaging.PyPackageManager
import com.jetbrains.python.packaging.PyRequirement
import com.jetbrains.python.packaging.PyRequirementParser
import com.jetbrains.python.packaging.common.PythonOutdatedPackage
import com.jetbrains.python.packaging.common.PythonPackage
import com.jetbrains.python.packaging.management.PythonPackageManager
import com.jetbrains.python.pathValidation.PlatformAndRoot
import com.jetbrains.python.pathValidation.ValidationRequest
import com.jetbrains.python.pathValidation.validateExecutableFile
@@ -37,7 +30,6 @@ import com.jetbrains.python.venvReader.VirtualEnvReader
import io.github.z4kn4fein.semver.Version
import io.github.z4kn4fein.semver.toVersion
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jetbrains.annotations.ApiStatus.Internal
import org.jetbrains.annotations.NonNls
@@ -139,28 +131,6 @@ suspend fun setupPoetry(projectPath: Path, python: String?, installPackages: Boo
return runPoetry(projectPath, "env", "info", "-p")
}
internal fun runPoetryInBackground(module: Module, args: List<String>, @NlsSafe description: String) {
service<PythonSdkCoroutineService>().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<String>?, projectPath: @SystemIndependent @NonNls String?): List<PyDetectedSdk> {
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)) }
@@ -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) } }
@@ -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<Boolean>("PyProjectToml.notification.active")
private val documentChangedMutex = Mutex()
override fun documentChanged(event: DocumentEvent) {
service<PythonSdkCoroutineService>().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<PythonSdkCoroutineService>().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<PoetryProjectTomlListener>("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<PythonSdkCoroutineService>().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)
}
}
@@ -169,7 +169,7 @@ class PyAddNewPoetryPanel(
private fun update() {
service<PythonSdkCoroutineService>().cs.launch {
selectedModule?.let {
installPackagesCheckBox.isEnabled = pyProjectToml(it) != null
installPackagesCheckBox.isEnabled = findPyProjectToml(it) != null
}
}
}
@@ -29,4 +29,7 @@ interface UvLowLevel {
suspend fun listPackages(): Result<List<PythonPackage>>
suspend fun listOutdatedPackages(): Result<List<PythonOutdatedPackage>>
suspend fun sync(): Result<String>
suspend fun lock(): Result<String>
}
@@ -67,6 +67,14 @@ internal class UvPackageManager(project: Project, sdk: Sdk, private val uv: UvLo
return uv.listPackages()
}
suspend fun sync(): Result<String> {
return uv.sync()
}
suspend fun lock(): Result<String> {
return uv.lock()
}
}
class UvPackageManagerProvider : PythonPackageManagerProvider {
@@ -172,6 +172,14 @@ private class UvLowLevelImpl(val cwd: Path, private val uvCli: UvCli) : UvLowLev
return pythons
}
override suspend fun sync(): Result<String> {
return uvCli.runUv(cwd, "sync")
}
override suspend fun lock(): Result<String> {
return uvCli.runUv(cwd, "lock")
}
}
fun createUvLowLevel(cwd: Path, uvCli: UvCli = createUvCli()): UvLowLevel {
@@ -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;
@@ -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<UvPackageManager, String>() {
override fun getManager(e: AnActionEvent): UvPackageManager? = e.getPythonPackageManager()
}
internal class UvSyncAction() : UvPackageManagerAction() {
override suspend fun execute(e: AnActionEvent, manager: UvPackageManager): Result<String, PyError> {
return manager.sync().asPythonResult()
}
}
internal class UvLockAction() : UvPackageManagerAction() {
override suspend fun execute(e: AnActionEvent, manager: UvPackageManager): Result<String, PyError> {
return manager.lock().asPythonResult()
}
}