[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
This commit is contained in:
Vitaly Legchilkin
2025-05-08 12:41:11 +02:00
committed by intellij-monorepo-bot
parent ae542388a8
commit c5ca662b4b
23 changed files with 427 additions and 213 deletions

View File

@@ -572,9 +572,6 @@ The Python plug-in provides smart editing for Python scripts. The feature set of
<suggestedRefactoringSupport language="Python"
implementationClass="com.jetbrains.python.refactoring.suggested.PySuggestedRefactoringSupport"/>
<!-- Poetry -->
<editorFactoryListener implementation="com.jetbrains.python.sdk.poetry.PoetryPyProjectTomlWatcher"/>
<!-- Targets API -->
<registryKey key="enable.conda.on.targets" defaultValue="false" description="Enables Conda configuration on targets."/>
<registryKey key="python.packaging.tool.use.project.location.as.working.dir" defaultValue="false"
@@ -1048,6 +1045,40 @@ The Python plug-in provides smart editing for Python scripts. The feature set of
<add-to-group group-id="Internal"/>
</group>
<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>
<extensions defaultExtensionNs="com.intellij.spellchecker">

View File

@@ -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
@@ -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

View File

@@ -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, ExecException> {
val envRuntime = runtime.withEnv(HatchConstants.AppEnvVars.ENV to envName)
suspend fun run(envName: String? = null, vararg command: String): Result<String, ExecException> {
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")
}
}

View File

@@ -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<String, PyError>
suspend fun syncDependencies(envName: String? = null): Result<String, PyError>
suspend fun isHatchManagedProject(): Result<Boolean, PyError>
@@ -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<HatchService, PyError> {
return CliBasedHatchService(hatchExecutablePath = hatchExecutablePath, workingDirectoryPath = this)
suspend fun Path?.getHatchService(hatchExecutablePath: Path? = null, hatchEnvironmentName: String? = null): Result<HatchService, PyError> {
return CliBasedHatchService(hatchExecutablePath = hatchExecutablePath, workingDirectoryPath = this, hatchEnvironmentName = hatchEnvironmentName)
}
/**

View File

@@ -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<CliBasedHatchService, PyError> {
suspend operator fun invoke(workingDirectoryPath: Path?, hatchExecutablePath: Path? = null, hatchEnvironmentName: String? = null): Result<CliBasedHatchService, PyError> {
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<String, PyError> {
override suspend fun syncDependencies(envName: String?): Result<String, PyError> {
return withContext(Dispatchers.IO) {
hatchRuntime.hatchCli().run(envName, "python", "--version")
}

View File

@@ -70,7 +70,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()
}

View File

@@ -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 {

View File

@@ -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()
}
}

View File

@@ -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<Mutex>("${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 <V> run(
project: Project,
holder: UserDataHolder,
title: @ProgressTitle String,
runnable: suspend () -> Result<V, PyError>,
): Result<V, PyError> {
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
}
}
}
}

View File

@@ -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<T : PythonPackageManager, V> : DumbAwareAction() {
protected val errorSink: ErrorSink = ShowingMessageErrorSync
protected val scope: CoroutineScope = service<PythonSdkCoroutineService>().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<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
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<V, PyError> {
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 <reified T : PythonPackageManager> 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 <V> PythonPackageManager.runSynchronized(
title: @ProgressTitle String,
runnable: suspend () -> Result<V, PyError>,
): Result<V, PyError> {
return CancellableJobSerialRunner.run(this.project, this.sdk, title, runnable)
}

View File

@@ -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;

View File

@@ -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()
}

View File

@@ -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<PoetryConfigServic
val hasPoetryToml = poetryToml(module) != null
if (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", isInProjectEnv.toString(), "--local")
}
}

View File

@@ -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
}

View File

@@ -1,29 +1,22 @@
// 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.python.community.impl.poetry.poetryPath
import com.intellij.util.SystemProperties
import com.jetbrains.python.PyBundle
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
@@ -31,7 +24,6 @@ import com.jetbrains.python.sdk.*
import com.jetbrains.python.venvReader.VirtualEnvReader
import io.github.z4kn4fein.semver.Version
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import io.github.z4kn4fein.semver.toVersion
import org.jetbrains.annotations.ApiStatus.Internal
@@ -126,28 +118,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)) }

View File

@@ -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) } }

View File

@@ -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<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)
}
}

View File

@@ -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
}
}
}

View File

@@ -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>
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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()
}
}