PY-80189 Introduce interfaces and implementations to extract dependencies from pyproject.toml files for UV and Poetry SDKs. New extractors handle top-level dependencies and integrate with respective package managers. Tests verify behavior for empty and populated dependency scenarios.

GitOrigin-RevId: d4563cf5838ed9760cf4b65af35e87b1559cd6a9
This commit is contained in:
Timur Malanin
2025-05-22 00:54:01 +00:00
committed by intellij-monorepo-bot
parent 34e20741b0
commit e384c86d45
13 changed files with 187 additions and 8 deletions
@@ -767,6 +767,10 @@
interface="com.jetbrains.python.packaging.management.PythonPackageManagerProvider"
dynamic="true"/>
<extensionPoint qualifiedName="Pythonid.PyProjectDependenciesExtractorProvider"
dynamic="true"
interface="com.jetbrains.python.packaging.PythonDependenciesExtractorProvider"/>
<extensionPoint qualifiedName="Pythonid.customProcessHandlerProvider"
interface="com.jetbrains.python.run.PyCustomProcessHandlerProvider"
dynamic="true"/>
@@ -884,6 +888,9 @@
<pythonPackageManagerProvider implementation="com.jetbrains.python.packaging.pip.PipPackageManagerProvider" order="last"/>
<pythonPackageManagerProvider implementation="com.jetbrains.python.packaging.conda.CondaPackageManagerProvider"/>
<PyProjectDependenciesExtractorProvider implementation="com.jetbrains.python.sdk.poetry.PoetryDependenciesExtractorProvider"/>
<PyProjectDependenciesExtractorProvider implementation="com.jetbrains.python.sdk.uv.UvDependenciesExtractorProvider"/>
<PythonPackageRequiresExtractorProvider implementation="com.jetbrains.python.poetry.packaging.PoetryRequiresExtractorProvider"/>
<PythonPackageRequiresExtractorProvider implementation="com.jetbrains.python.uv.packaging.UvPackageRequiresExtractorProvider"/>
@@ -0,0 +1,29 @@
// 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
import com.intellij.openapi.extensions.ExtensionPointName
import com.intellij.openapi.module.Module
import com.intellij.openapi.projectRoots.Sdk
import com.jetbrains.python.packaging.common.PythonPackage
import org.jetbrains.annotations.ApiStatus
@ApiStatus.Internal
interface PythonDependenciesExtractor {
suspend fun extract(module: Module): List<PythonPackage>
companion object {
fun forSdk(sdk: Sdk): PythonDependenciesExtractor? =
PythonDependenciesExtractorProvider.EP_NAME.extensionList.firstNotNullOf { it.createExtractor(sdk) }
}
}
@ApiStatus.Internal
interface PythonDependenciesExtractorProvider {
fun createExtractor(sdk: Sdk): PythonDependenciesExtractor?
companion object {
val EP_NAME: ExtensionPointName<PythonDependenciesExtractorProvider> = ExtensionPointName.create<PythonDependenciesExtractorProvider>("Pythonid.PyProjectDependenciesExtractorProvider")
}
}
@@ -19,6 +19,7 @@ internal class CompositePythonPackageManager(
@Volatile
override var installedPackages: List<PythonPackage> = emptyList()
override var dependencies: List<PythonPackage> = emptyList()
override var repositoryManager: PythonRepositoryManager =
CompositePythonRepositoryManager(project, managers.map { it.repositoryManager })
@@ -91,6 +92,10 @@ internal class CompositePythonPackageManager(
}
}
override suspend fun reloadDependencies(): List<PythonPackage> = dependencies
override fun listDependencies(): List<PythonPackage> = dependencies
private suspend fun processPackageOperation(
errorMessageKey: String,
operation: suspend (PythonPackageManager) -> Result<*>,
@@ -41,6 +41,7 @@ private fun PythonPackageInstallRequest.buildInstallationArguments(): Result<Lis
class CondaPackageManager(project: Project, sdk: Sdk) : PythonPackageManager(project, sdk) {
@Volatile
override var installedPackages: List<PythonPackage> = emptyList()
override var dependencies: List<PythonPackage> = emptyList()
override val repositoryManager: PythonRepositoryManager = CondaRepositoryManger(project, sdk)
override suspend fun loadOutdatedPackagesCommand(): Result<List<PythonOutdatedPackage>> {
@@ -92,6 +93,10 @@ class CondaPackageManager(project: Project, sdk: Sdk) : PythonPackageManager(pro
Result.failure(ex)
}
override suspend fun reloadDependencies(): List<PythonPackage> = dependencies
override fun listDependencies(): List<PythonPackage> = dependencies
private fun parseCondaPackageList(text: String): List<CondaPackage> {
return text.lineSequence()
.filterNot { it.startsWith("#") }
@@ -43,6 +43,8 @@ fun PythonRepositoryPackageSpecification.toInstallRequest(): PythonPackageInstal
@ApiStatus.Experimental
abstract class PythonPackageManager(val project: Project, val sdk: Sdk) {
abstract var installedPackages: List<PythonPackage>
@get:ApiStatus.Internal @set:ApiStatus.Internal
protected abstract var dependencies: List<PythonPackage>
@ApiStatus.Internal
@Volatile
@@ -130,14 +132,16 @@ abstract class PythonPackageManager(val project: Project, val sdk: Sdk) {
abstract suspend fun installPackageCommand(installRequest: PythonPackageInstallRequest, options: List<String>): Result<Unit>
@ApiStatus.Internal
abstract suspend fun updatePackageCommand(specification: PythonRepositoryPackageSpecification): Result<Unit>
@ApiStatus.Internal
abstract suspend fun uninstallPackageCommand(pkg: PythonPackage): Result<Unit>
@ApiStatus.Internal
abstract suspend fun reloadPackagesCommand(): Result<List<PythonPackage>>
@ApiStatus.Internal
abstract suspend fun loadOutdatedPackagesCommand(): Result<List<PythonOutdatedPackage>>
@ApiStatus.Internal
abstract suspend fun reloadDependencies(): List<PythonPackage>
@ApiStatus.Internal
abstract fun listDependencies(): List<PythonPackage>
internal suspend fun refreshPaths() {
edtWriteAction {
@@ -43,6 +43,7 @@ private fun PythonPackageInstallRequest.buildPipInstallArguments(): List<String>
open class PipPythonPackageManager(project: Project, sdk: Sdk) : PythonPackageManager(project, sdk) {
@Volatile
override var installedPackages: List<PythonPackage> = emptyList()
override var dependencies: List<PythonPackage> = emptyList()
override val repositoryManager: PythonRepositoryManager = PipRepositoryManager(project)
override suspend fun installPackageCommand(installRequest: PythonPackageInstallRequest, options: List<String>): Result<Unit> {
@@ -109,6 +110,10 @@ open class PipPythonPackageManager(project: Project, sdk: Sdk) : PythonPackageMa
return Result.failure(ex)
}
}
override suspend fun reloadDependencies(): List<PythonPackage> = dependencies
override fun listDependencies(): List<PythonPackage> = dependencies
}
@ApiStatus.Internal
@@ -0,0 +1,43 @@
// 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.openapi.module.Module
import com.intellij.openapi.projectRoots.Sdk
import com.jetbrains.python.packaging.PythonDependenciesExtractor
import com.jetbrains.python.packaging.PythonDependenciesExtractorProvider
import com.jetbrains.python.packaging.common.PythonPackage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Extractor for top-level dependencies from pyproject.toml for Poetry SDKs.
*/
internal class PoetryDependenciesExtractor(private val sdk: Sdk) : PythonDependenciesExtractor {
override suspend fun extract(module: Module): List<PythonPackage> {
val output = runPoetryWithSdk(sdk, "show", "--top-level")
val data = output.getOrNull()?.ifEmpty { return emptyList() } ?: return emptyList()
return parsePackageData(data.lines())
}
private suspend fun parsePackageData(lines: List<String>): List<PythonPackage> = withContext(Dispatchers.Default) {
val packageList = mutableListOf<PythonPackage>()
for (line in lines) {
val parts = line.split(WHITESPACE_REGEX)
val packageName = parts[0]
val version = parts.getOrElse(1) { "" }
packageList.add(PythonPackage(packageName, version, false))
}
packageList
}
}
private class PoetryDependenciesExtractorProvider: PythonDependenciesExtractorProvider {
override fun createExtractor(sdk: Sdk): PythonDependenciesExtractor? {
if (!sdk.isPoetry) return null
return PoetryDependenciesExtractor(sdk)
}
}
private val WHITESPACE_REGEX = Regex("\\s+")
@@ -2,7 +2,9 @@
package com.jetbrains.python.sdk.poetry
import com.intellij.openapi.project.Project
import com.intellij.openapi.project.modules
import com.intellij.openapi.projectRoots.Sdk
import com.jetbrains.python.packaging.PythonDependenciesExtractor
import com.jetbrains.python.packaging.common.PythonOutdatedPackage
import com.jetbrains.python.packaging.common.PythonPackage
import com.jetbrains.python.packaging.common.PythonRepositoryPackageSpecification
@@ -10,6 +12,7 @@ import com.jetbrains.python.packaging.management.PythonPackageInstallRequest
import com.jetbrains.python.packaging.management.PythonPackageManager
import com.jetbrains.python.packaging.management.PythonRepositoryManager
import com.jetbrains.python.packaging.pip.PipRepositoryManager
import com.jetbrains.python.sdk.pythonSdk
import org.jetbrains.annotations.ApiStatus
import org.jetbrains.annotations.TestOnly
@@ -17,6 +20,7 @@ import org.jetbrains.annotations.TestOnly
class PoetryPackageManager(project: Project, sdk: Sdk) : PythonPackageManager(project, sdk) {
@Volatile
override var installedPackages: List<PythonPackage> = emptyList()
override var dependencies: List<PythonPackage> = emptyList()
override val repositoryManager: PythonRepositoryManager = PipRepositoryManager(project)
@@ -64,6 +68,15 @@ class PoetryPackageManager(project: Project, sdk: Sdk) : PythonPackageManager(pr
it.values.toList()
}
override suspend fun reloadDependencies(): List<PythonPackage> {
val dependenciesExtractor = PythonDependenciesExtractor.forSdk(sdk) ?: return emptyList()
val targetModule = project.modules.find { it.pythonSdk == sdk } ?: return emptyList()
dependencies = dependenciesExtractor.extract(targetModule)
return dependencies
}
override fun listDependencies(): List<PythonPackage> = dependencies
private fun PythonRepositoryPackageSpecification.getPackageWithVersionInPoetryFormat(): String {
return versionSpec?.let { "$name@${it.presentableText}" } ?: name
}
+2 -1
View File
@@ -1,4 +1,4 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
// 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.uv
import com.jetbrains.python.errorProcessing.PyExecResult
@@ -35,6 +35,7 @@ interface UvLowLevel {
suspend fun listPackages(): PyExecResult<List<PythonPackage>>
suspend fun listOutdatedPackages(): PyResult<List<PythonOutdatedPackage>>
suspend fun listTopLevelPackages(): PyResult<List<PythonPackage>>
suspend fun listPackageRequirements(name: PythonPackage): PyResult<List<NormalizedPythonPackageName>>
suspend fun isProjectSynced(inexact: Boolean): PyExecResult<Boolean>
@@ -0,0 +1,30 @@
// 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.uv
import com.intellij.openapi.module.Module
import com.intellij.openapi.projectRoots.Sdk
import com.jetbrains.python.packaging.PythonDependenciesExtractor
import com.jetbrains.python.packaging.PythonDependenciesExtractorProvider
import com.jetbrains.python.packaging.common.PythonPackage
import com.jetbrains.python.sdk.basePath
import com.jetbrains.python.sdk.uv.impl.createUvCli
import com.jetbrains.python.sdk.uv.impl.createUvLowLevel
import java.nio.file.Path
/**
* Extractor for top-level dependencies from pyproject.toml for UV SDKs.
*/
internal class UvDependenciesExtractor(private val uvWorkingDirectory: Path?) : PythonDependenciesExtractor {
override suspend fun extract(module: Module): List<PythonPackage> {
val uvWorkingDirectory = uvWorkingDirectory ?: Path.of(module.basePath!!)
val uv = createUvLowLevel(uvWorkingDirectory, createUvCli())
return uv.listTopLevelPackages().successOrNull ?: emptyList()
}
}
private class UvDependenciesExtractorProvider: PythonDependenciesExtractorProvider {
override fun createExtractor(sdk: Sdk): PythonDependenciesExtractor? {
val data = sdk.sdkAdditionalData as? UvSdkAdditionalData ?: return null
return UvDependenciesExtractor(data.uvWorkingDirectory)
}
}
@@ -2,20 +2,24 @@
package com.jetbrains.python.sdk.uv
import com.intellij.openapi.project.Project
import com.intellij.openapi.project.modules
import com.intellij.openapi.projectRoots.Sdk
import com.jetbrains.python.errorProcessing.PyExecResult
import com.jetbrains.python.errorProcessing.asKotlinResult
import com.jetbrains.python.packaging.PythonDependenciesExtractor
import com.jetbrains.python.packaging.common.PythonOutdatedPackage
import com.jetbrains.python.packaging.common.PythonPackage
import com.jetbrains.python.packaging.common.PythonRepositoryPackageSpecification
import com.jetbrains.python.packaging.management.*
import com.jetbrains.python.packaging.pip.PipRepositoryManager
import com.jetbrains.python.sdk.pythonSdk
import com.jetbrains.python.sdk.uv.impl.createUvCli
import com.jetbrains.python.sdk.uv.impl.createUvLowLevel
import java.nio.file.Path
internal class UvPackageManager(project: Project, sdk: Sdk, private val uv: UvLowLevel) : PythonPackageManager(project, sdk) {
override var installedPackages: List<PythonPackage> = emptyList()
override var dependencies: List<PythonPackage> = emptyList()
override val repositoryManager: PythonRepositoryManager = PipRepositoryManager(project)
@@ -72,6 +76,15 @@ internal class UvPackageManager(project: Project, sdk: Sdk, private val uv: UvLo
suspend fun lock(): PyExecResult<String> {
return uv.lock()
}
override suspend fun reloadDependencies(): List<PythonPackage> {
val dependenciesExtractor = PythonDependenciesExtractor.forSdk(sdk) ?: return emptyList()
val targetModule = project.modules.find { it.pythonSdk == sdk } ?: return emptyList()
dependencies = dependenciesExtractor.extract(targetModule)
return dependencies
}
override fun listDependencies(): List<PythonPackage> = dependencies
}
class UvPackageManagerProvider : PythonPackageManagerProvider {
@@ -5,12 +5,9 @@ import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.intellij.util.io.delete
import com.jetbrains.python.errorProcessing.ExecError
import com.jetbrains.python.errorProcessing.ExecErrorReason
import com.jetbrains.python.errorProcessing.PyError
import com.jetbrains.python.errorProcessing.*
import com.jetbrains.python.errorProcessing.PyExecResult
import com.jetbrains.python.errorProcessing.PyResult
import com.jetbrains.python.errorProcessing.failure
import com.jetbrains.python.onFailure
import com.jetbrains.python.packaging.common.NormalizedPythonPackageName
import com.jetbrains.python.packaging.common.PythonOutdatedPackage
@@ -21,6 +18,8 @@ import com.jetbrains.python.sdk.uv.UvCli
import com.jetbrains.python.sdk.uv.UvLowLevel
import com.jetbrains.python.venvReader.VirtualEnvReader
import com.jetbrains.python.venvReader.tryResolvePath
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.nio.file.Path
import kotlin.io.path.exists
import kotlin.io.path.pathString
@@ -128,6 +127,13 @@ private class UvLowLevelImpl(val cwd: Path, private val uvCli: UvCli) : UvLowLev
}
}
override suspend fun listTopLevelPackages(): PyResult<List<PythonPackage>> {
val out = uvCli.runUv(cwd, "tree", "--depth=1")
.getOr { return it }
return PyExecResult.success(parsePackageList(out))
}
override suspend fun listPackageRequirements(name: PythonPackage): PyResult<List<NormalizedPythonPackageName>> {
val out = uvCli.runUv(cwd, "pip", "show", name.name)
.getOr { return it }
@@ -249,6 +255,19 @@ private class UvLowLevelImpl(val cwd: Path, private val uvCli: UvCli) : UvLowLev
return uvCli.runUv(cwd, "lock")
}
suspend fun parsePackageList(input: String): List<PythonPackage> = withContext(Dispatchers.Default) {
val packageList = mutableListOf<PythonPackage>()
for (line in input.lines().drop(1)) {
val parts = line.trim().split(WHITESPACE_REGEX).drop(1)
val packageName = parts[0]
val version = parts.getOrElse(1) { "" }
packageList.add(PythonPackage(packageName, version, false))
}
packageList
}
private fun parsePackageRequirements(input: String): List<NormalizedPythonPackageName> {
val requiresLine = input.lines().find { it.startsWith(REQUIRES_LINE_PREFIX) } ?: return emptyList()
@@ -261,6 +280,7 @@ private class UvLowLevelImpl(val cwd: Path, private val uvCli: UvCli) : UvLowLev
}
companion object {
private val WHITESPACE_REGEX = Regex("\\s+")
private const val REQUIRES_LINE_PREFIX = "Requires:"
}
}
@@ -13,7 +13,7 @@ import org.jetbrains.annotations.TestOnly
class TestPythonPackageManager(project: Project, sdk: Sdk) : PythonPackageManager(project, sdk) {
override var installedPackages: List<PythonPackage> = DEFAULT_PACKAGES.toMutableList()
override var dependencies: List<PythonPackage> = emptyList()
private var packageNames: List<String> = emptyList()
private var packageDetails: PythonPackageDetails? = null
@@ -56,6 +56,10 @@ class TestPythonPackageManager(project: Project, sdk: Sdk) : PythonPackageManage
return Result.success(installedPackages)
}
override suspend fun reloadDependencies(): List<PythonPackage> = dependencies
override fun listDependencies(): List<PythonPackage> = dependencies
private fun findPackageByName(name: String): PythonPackage? {
return installedPackages.find { it.name == name }
}