From e384c86d451d9e116864bb985da86a1028eb6502 Mon Sep 17 00:00:00 2001 From: Timur Malanin Date: Thu, 22 May 2025 00:54:01 +0000 Subject: [PATCH] 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 --- .../intellij.python.community.impl.xml | 7 +++ .../packaging/PythonDependenciesExtractor.kt | 29 +++++++++++++ .../conda/CompositePythonPackageManager.kt | 5 +++ .../packaging/conda/CondaPackageManager.kt | 5 +++ .../management/PythonPackageManager.kt | 8 +++- .../packaging/pip/PipPythonPackageManager.kt | 5 +++ .../sdk/poetry/PoetryDependenciesExtractor.kt | 43 +++++++++++++++++++ .../python/sdk/poetry/PoetryPackageManager.kt | 13 ++++++ python/src/com/jetbrains/python/sdk/uv/Uv.kt | 3 +- .../python/sdk/uv/UvDependenciesExtractor.kt | 30 +++++++++++++ .../python/sdk/uv/UvPackageManager.kt | 13 ++++++ .../python/sdk/uv/impl/UvLowLevel.kt | 28 ++++++++++-- .../management/TestPackageManager.kt | 6 ++- 13 files changed, 187 insertions(+), 8 deletions(-) create mode 100644 python/src/com/jetbrains/python/packaging/PythonDependenciesExtractor.kt create mode 100644 python/src/com/jetbrains/python/sdk/poetry/PoetryDependenciesExtractor.kt create mode 100644 python/src/com/jetbrains/python/sdk/uv/UvDependenciesExtractor.kt diff --git a/python/pluginResources/intellij.python.community.impl.xml b/python/pluginResources/intellij.python.community.impl.xml index 1e26de2775b2..a48958a40737 100644 --- a/python/pluginResources/intellij.python.community.impl.xml +++ b/python/pluginResources/intellij.python.community.impl.xml @@ -767,6 +767,10 @@ interface="com.jetbrains.python.packaging.management.PythonPackageManagerProvider" dynamic="true"/> + + @@ -884,6 +888,9 @@ + + + diff --git a/python/src/com/jetbrains/python/packaging/PythonDependenciesExtractor.kt b/python/src/com/jetbrains/python/packaging/PythonDependenciesExtractor.kt new file mode 100644 index 000000000000..47b1ff60f1ca --- /dev/null +++ b/python/src/com/jetbrains/python/packaging/PythonDependenciesExtractor.kt @@ -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 + + 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 = ExtensionPointName.create("Pythonid.PyProjectDependenciesExtractorProvider") + } +} \ No newline at end of file diff --git a/python/src/com/jetbrains/python/packaging/conda/CompositePythonPackageManager.kt b/python/src/com/jetbrains/python/packaging/conda/CompositePythonPackageManager.kt index be8b3950a7a2..c7da373784d4 100644 --- a/python/src/com/jetbrains/python/packaging/conda/CompositePythonPackageManager.kt +++ b/python/src/com/jetbrains/python/packaging/conda/CompositePythonPackageManager.kt @@ -19,6 +19,7 @@ internal class CompositePythonPackageManager( @Volatile override var installedPackages: List = emptyList() + override var dependencies: List = emptyList() override var repositoryManager: PythonRepositoryManager = CompositePythonRepositoryManager(project, managers.map { it.repositoryManager }) @@ -91,6 +92,10 @@ internal class CompositePythonPackageManager( } } + override suspend fun reloadDependencies(): List = dependencies + + override fun listDependencies(): List = dependencies + private suspend fun processPackageOperation( errorMessageKey: String, operation: suspend (PythonPackageManager) -> Result<*>, diff --git a/python/src/com/jetbrains/python/packaging/conda/CondaPackageManager.kt b/python/src/com/jetbrains/python/packaging/conda/CondaPackageManager.kt index 823d5bcb5c2f..6b8be72b4167 100644 --- a/python/src/com/jetbrains/python/packaging/conda/CondaPackageManager.kt +++ b/python/src/com/jetbrains/python/packaging/conda/CondaPackageManager.kt @@ -41,6 +41,7 @@ private fun PythonPackageInstallRequest.buildInstallationArguments(): Result = emptyList() + override var dependencies: List = emptyList() override val repositoryManager: PythonRepositoryManager = CondaRepositoryManger(project, sdk) override suspend fun loadOutdatedPackagesCommand(): Result> { @@ -92,6 +93,10 @@ class CondaPackageManager(project: Project, sdk: Sdk) : PythonPackageManager(pro Result.failure(ex) } + override suspend fun reloadDependencies(): List = dependencies + + override fun listDependencies(): List = dependencies + private fun parseCondaPackageList(text: String): List { return text.lineSequence() .filterNot { it.startsWith("#") } diff --git a/python/src/com/jetbrains/python/packaging/management/PythonPackageManager.kt b/python/src/com/jetbrains/python/packaging/management/PythonPackageManager.kt index defd38ddd9c4..534be1b9c9be 100644 --- a/python/src/com/jetbrains/python/packaging/management/PythonPackageManager.kt +++ b/python/src/com/jetbrains/python/packaging/management/PythonPackageManager.kt @@ -43,6 +43,8 @@ fun PythonRepositoryPackageSpecification.toInstallRequest(): PythonPackageInstal @ApiStatus.Experimental abstract class PythonPackageManager(val project: Project, val sdk: Sdk) { abstract var installedPackages: List + @get:ApiStatus.Internal @set:ApiStatus.Internal + protected abstract var dependencies: List @ApiStatus.Internal @Volatile @@ -130,14 +132,16 @@ abstract class PythonPackageManager(val project: Project, val sdk: Sdk) { abstract suspend fun installPackageCommand(installRequest: PythonPackageInstallRequest, options: List): Result @ApiStatus.Internal abstract suspend fun updatePackageCommand(specification: PythonRepositoryPackageSpecification): Result - @ApiStatus.Internal abstract suspend fun uninstallPackageCommand(pkg: PythonPackage): Result - @ApiStatus.Internal abstract suspend fun reloadPackagesCommand(): Result> @ApiStatus.Internal abstract suspend fun loadOutdatedPackagesCommand(): Result> + @ApiStatus.Internal + abstract suspend fun reloadDependencies(): List + @ApiStatus.Internal + abstract fun listDependencies(): List internal suspend fun refreshPaths() { edtWriteAction { diff --git a/python/src/com/jetbrains/python/packaging/pip/PipPythonPackageManager.kt b/python/src/com/jetbrains/python/packaging/pip/PipPythonPackageManager.kt index db9feae9917c..7c1a8320ac3e 100644 --- a/python/src/com/jetbrains/python/packaging/pip/PipPythonPackageManager.kt +++ b/python/src/com/jetbrains/python/packaging/pip/PipPythonPackageManager.kt @@ -43,6 +43,7 @@ private fun PythonPackageInstallRequest.buildPipInstallArguments(): List open class PipPythonPackageManager(project: Project, sdk: Sdk) : PythonPackageManager(project, sdk) { @Volatile override var installedPackages: List = emptyList() + override var dependencies: List = emptyList() override val repositoryManager: PythonRepositoryManager = PipRepositoryManager(project) override suspend fun installPackageCommand(installRequest: PythonPackageInstallRequest, options: List): Result { @@ -109,6 +110,10 @@ open class PipPythonPackageManager(project: Project, sdk: Sdk) : PythonPackageMa return Result.failure(ex) } } + + override suspend fun reloadDependencies(): List = dependencies + + override fun listDependencies(): List = dependencies } @ApiStatus.Internal diff --git a/python/src/com/jetbrains/python/sdk/poetry/PoetryDependenciesExtractor.kt b/python/src/com/jetbrains/python/sdk/poetry/PoetryDependenciesExtractor.kt new file mode 100644 index 000000000000..3b496849d3f7 --- /dev/null +++ b/python/src/com/jetbrains/python/sdk/poetry/PoetryDependenciesExtractor.kt @@ -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 { + 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): List = withContext(Dispatchers.Default) { + val packageList = mutableListOf() + + 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+") \ No newline at end of file diff --git a/python/src/com/jetbrains/python/sdk/poetry/PoetryPackageManager.kt b/python/src/com/jetbrains/python/sdk/poetry/PoetryPackageManager.kt index d6581d4ed2e8..aad5c54ca84b 100644 --- a/python/src/com/jetbrains/python/sdk/poetry/PoetryPackageManager.kt +++ b/python/src/com/jetbrains/python/sdk/poetry/PoetryPackageManager.kt @@ -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 = emptyList() + override var dependencies: List = 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 { + 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 = dependencies + private fun PythonRepositoryPackageSpecification.getPackageWithVersionInPoetryFormat(): String { return versionSpec?.let { "$name@${it.presentableText}" } ?: name } diff --git a/python/src/com/jetbrains/python/sdk/uv/Uv.kt b/python/src/com/jetbrains/python/sdk/uv/Uv.kt index e75a84486c4a..c28930796d61 100644 --- a/python/src/com/jetbrains/python/sdk/uv/Uv.kt +++ b/python/src/com/jetbrains/python/sdk/uv/Uv.kt @@ -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> suspend fun listOutdatedPackages(): PyResult> + suspend fun listTopLevelPackages(): PyResult> suspend fun listPackageRequirements(name: PythonPackage): PyResult> suspend fun isProjectSynced(inexact: Boolean): PyExecResult diff --git a/python/src/com/jetbrains/python/sdk/uv/UvDependenciesExtractor.kt b/python/src/com/jetbrains/python/sdk/uv/UvDependenciesExtractor.kt new file mode 100644 index 000000000000..6cb01e5b3924 --- /dev/null +++ b/python/src/com/jetbrains/python/sdk/uv/UvDependenciesExtractor.kt @@ -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 { + 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) + } +} \ 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 983b8f4627b6..5cf0fdc4e982 100644 --- a/python/src/com/jetbrains/python/sdk/uv/UvPackageManager.kt +++ b/python/src/com/jetbrains/python/sdk/uv/UvPackageManager.kt @@ -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 = emptyList() + override var dependencies: List = 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 { return uv.lock() } + + override suspend fun reloadDependencies(): List { + 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 = dependencies } 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 d94a2064c670..2258fe9fec52 100644 --- a/python/src/com/jetbrains/python/sdk/uv/impl/UvLowLevel.kt +++ b/python/src/com/jetbrains/python/sdk/uv/impl/UvLowLevel.kt @@ -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> { + val out = uvCli.runUv(cwd, "tree", "--depth=1") + .getOr { return it } + + return PyExecResult.success(parsePackageList(out)) + } + override suspend fun listPackageRequirements(name: PythonPackage): PyResult> { 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 = withContext(Dispatchers.Default) { + val packageList = mutableListOf() + + 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 { 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:" } } diff --git a/python/testSrc/com/jetbrains/python/packaging/management/TestPackageManager.kt b/python/testSrc/com/jetbrains/python/packaging/management/TestPackageManager.kt index 60dd36305daa..f57b0ca12054 100644 --- a/python/testSrc/com/jetbrains/python/packaging/management/TestPackageManager.kt +++ b/python/testSrc/com/jetbrains/python/packaging/management/TestPackageManager.kt @@ -13,7 +13,7 @@ import org.jetbrains.annotations.TestOnly class TestPythonPackageManager(project: Project, sdk: Sdk) : PythonPackageManager(project, sdk) { override var installedPackages: List = DEFAULT_PACKAGES.toMutableList() - + override var dependencies: List = emptyList() private var packageNames: List = 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 = dependencies + + override fun listDependencies(): List = dependencies + private fun findPackageByName(name: String): PythonPackage? { return installedPackages.find { it.name == name } }