PY-80189 Introduced a unified PythonPackageRequiresExtractor interface for parsing package requirements for Python SDKs. Added implementations for Poetry and UV tools, including the necessary changes to handle normalized package names. Added corresponding unit tests to validate the extraction functionality.

GitOrigin-RevId: add74cf74974af6608d88546d86fe72900eee271
This commit is contained in:
Timur Malanin
2025-05-20 17:23:55 +00:00
committed by intellij-monorepo-bot
parent 2e311368af
commit a54f307202
7 changed files with 168 additions and 3 deletions
@@ -778,6 +778,10 @@
<extensionPoint qualifiedName="com.jetbrains.python.configuration.pyIntegratedToolsTestPanelCustomizer"
interface="com.jetbrains.python.configuration.PyIntegratedToolsTestPanelCustomizer"
dynamic="true"/>
<extensionPoint qualifiedName="Pythonid.PythonPackageRequiresExtractorProvider"
interface="com.jetbrains.python.packaging.packageRequires.PythonPackageRequiresExtractorProvider"
dynamic="true"/>
</extensionPoints>
<extensions defaultExtensionNs="Pythonid">
@@ -880,6 +884,9 @@
<pythonPackageManagerProvider implementation="com.jetbrains.python.packaging.pip.PipPackageManagerProvider" order="last"/>
<pythonPackageManagerProvider implementation="com.jetbrains.python.packaging.conda.CondaPackageManagerProvider"/>
<PythonPackageRequiresExtractorProvider implementation="com.jetbrains.python.poetry.packaging.PoetryRequiresExtractorProvider"/>
<PythonPackageRequiresExtractorProvider implementation="com.jetbrains.python.uv.packaging.UvPackageRequiresExtractorProvider"/>
<systemPythonProvider implementation="com.jetbrains.python.sdk.uv.UvSystemPythonProvider"/>
<inspectionExtension implementation="com.jetbrains.python.sdk.configuration.PyInterpreterInspectionSuppressor"/>
@@ -6,14 +6,24 @@ import com.jetbrains.python.packaging.pyRequirementVersionSpec
import com.jetbrains.python.packaging.repository.PyPackageRepository
import com.jetbrains.python.packaging.requirement.PyRequirementRelation
import com.jetbrains.python.packaging.requirement.PyRequirementVersionSpec
import org.jetbrains.annotations.ApiStatus
import org.jetbrains.annotations.Nls
@ApiStatus.Internal
@JvmInline
value class NormalizedPythonPackageName private constructor(val name: String) {
companion object {
fun from(name: String): NormalizedPythonPackageName =
NormalizedPythonPackageName(normalizePackageName(name))
}
}
open class PythonPackage(name: String, val version: String, val isEditableMode: Boolean) {
companion object {
private const val HASH_MULTIPLIER = 31
}
val name: String = normalizePackageName(name)
val name: String = NormalizedPythonPackageName.from(name).name
val presentableName: String = name
override fun toString(): String {
@@ -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.packaging.packageRequires
import com.intellij.openapi.extensions.ExtensionPointName
import com.intellij.openapi.module.Module
import com.intellij.openapi.projectRoots.Sdk
import com.jetbrains.python.packaging.common.NormalizedPythonPackageName
import com.jetbrains.python.packaging.common.PythonPackage
import org.jetbrains.annotations.ApiStatus
@ApiStatus.Internal
interface PythonPackageRequiresExtractor {
suspend fun extract(pkg: PythonPackage, module: Module): List<NormalizedPythonPackageName>
companion object {
fun forSdk(sdk: Sdk): PythonPackageRequiresExtractor? =
PythonPackageRequiresExtractorProvider.EP_NAME.extensionList.firstNotNullOf { it.createExtractor(sdk) }
}
}
@ApiStatus.Internal
interface PythonPackageRequiresExtractorProvider {
fun createExtractor(sdk: Sdk): PythonPackageRequiresExtractor?
companion object {
val EP_NAME: ExtensionPointName<PythonPackageRequiresExtractorProvider> = ExtensionPointName.create<PythonPackageRequiresExtractorProvider>("Pythonid.PythonPackageRequiresExtractorProvider")
}
}
@@ -0,0 +1,60 @@
// 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.diagnostic.thisLogger
import com.intellij.openapi.module.Module
import com.intellij.openapi.projectRoots.Sdk
import com.jetbrains.python.packaging.common.NormalizedPythonPackageName
import com.jetbrains.python.packaging.common.PythonPackage
import com.jetbrains.python.packaging.packageRequires.PythonPackageRequiresExtractor
import com.jetbrains.python.packaging.packageRequires.PythonPackageRequiresExtractorProvider
import com.jetbrains.python.sdk.poetry.isPoetry
import com.jetbrains.python.sdk.poetry.runPoetryWithSdk
internal class PoetryPackageRequiresExtractor(private val sdk: Sdk) : PythonPackageRequiresExtractor {
override suspend fun extract(pkg: PythonPackage, module: Module): List<NormalizedPythonPackageName> {
val output = runPoetryWithSdk(sdk, "show", pkg.name)
val data = output.getOrElse {
thisLogger().info("extracting requires for package ${pkg.name}: error. Output: \n${it.stackTraceToString()}")
return emptyList()
}
return parsePackageData(data.lines())
}
private fun parsePackageData(lines: List<String>): List<NormalizedPythonPackageName> {
val packages = mutableListOf<NormalizedPythonPackageName>()
fun parseDependencyLine(line: String): NormalizedPythonPackageName? {
val depLine = line.removePrefix(DEPENDENCY_PREFIX).trim()
val name = depLine.split(" ", limit = 2).let { parts ->
parts.getOrNull(0)?.trim()
}
return name?.let { NormalizedPythonPackageName.Companion.from(name) }
}
var inDependenciesSection = false
for (line in lines) {
val trimmedLine = line.trim()
when {
trimmedLine == DEPENDENCIES_MARKER -> inDependenciesSection = true
inDependenciesSection && line.startsWith(DEPENDENCY_PREFIX) -> parseDependencyLine(line)?.let { packages.add(it) }
inDependenciesSection && trimmedLine.isEmpty() -> break
}
}
return packages
}
private companion object {
const val DEPENDENCIES_MARKER = "dependencies"
const val DEPENDENCY_PREFIX = " - "
}
}
private class PoetryRequiresExtractorProvider: PythonPackageRequiresExtractorProvider {
override fun createExtractor(sdk: Sdk): PythonPackageRequiresExtractor? {
if (!sdk.isPoetry) return null
return PoetryPackageRequiresExtractor(sdk)
}
}
+3 -1
View File
@@ -1,8 +1,9 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
// Copyright 2000-2024 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
import com.jetbrains.python.errorProcessing.PyResult
import com.jetbrains.python.packaging.common.NormalizedPythonPackageName
import com.jetbrains.python.packaging.common.PythonOutdatedPackage
import com.jetbrains.python.packaging.common.PythonPackage
import com.jetbrains.python.packaging.management.PythonPackageInstallRequest
@@ -34,6 +35,7 @@ interface UvLowLevel {
suspend fun listPackages(): PyExecResult<List<PythonPackage>>
suspend fun listOutdatedPackages(): PyResult<List<PythonOutdatedPackage>>
suspend fun listPackageRequirements(name: PythonPackage): PyResult<List<NormalizedPythonPackageName>>
suspend fun isProjectSynced(inexact: Boolean): PyExecResult<Boolean>
suspend fun isScriptSynced(inexact: Boolean, scriptPath: Path): PyExecResult<ScriptSyncCheckResult>
@@ -12,6 +12,7 @@ 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
import com.jetbrains.python.packaging.common.PythonPackage
import com.jetbrains.python.packaging.management.PythonPackageInstallRequest
@@ -127,6 +128,13 @@ private class UvLowLevelImpl(val cwd: Path, private val uvCli: UvCli) : UvLowLev
}
}
override suspend fun listPackageRequirements(name: PythonPackage): PyResult<List<NormalizedPythonPackageName>> {
val out = uvCli.runUv(cwd, "pip", "show", name.name)
.getOr { return it }
return PyExecResult.success(parsePackageRequirements(out))
}
override suspend fun installPackage(name: PythonPackageInstallRequest, options: List<String>): PyExecResult<Unit> {
uvCli.runUv(cwd, "pip", "install", name.formatPackageName(), *options.toTypedArray())
.onFailure { return PyResult.failure(it) }
@@ -235,11 +243,26 @@ private class UvLowLevelImpl(val cwd: Path, private val uvCli: UvCli) : UvLowLev
override suspend fun sync(): PyExecResult<String> {
return uvCli.runUv(cwd, "sync")
}
}
override suspend fun lock(): PyExecResult<String> {
return uvCli.runUv(cwd, "lock")
}
private fun parsePackageRequirements(input: String): List<NormalizedPythonPackageName> {
val requiresLine = input.lines().find { it.startsWith(REQUIRES_LINE_PREFIX) } ?: return emptyList()
return requiresLine
.removePrefix(REQUIRES_LINE_PREFIX)
.split(",")
.map { it.trim() }
.filter { it.isNotEmpty() }
.map { NormalizedPythonPackageName.from(it) }
}
companion object {
private const val REQUIRES_LINE_PREFIX = "Requires:"
}
}
fun createUvLowLevel(cwd: Path, uvCli: UvCli = createUvCli()): UvLowLevel {
@@ -0,0 +1,33 @@
// 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.diagnostic.thisLogger
import com.intellij.openapi.module.Module
import com.intellij.openapi.projectRoots.Sdk
import com.jetbrains.python.packaging.common.NormalizedPythonPackageName
import com.jetbrains.python.packaging.common.PythonPackage
import com.jetbrains.python.packaging.packageRequires.PythonPackageRequiresExtractor
import com.jetbrains.python.packaging.packageRequires.PythonPackageRequiresExtractorProvider
import com.jetbrains.python.sdk.basePath
import com.jetbrains.python.sdk.uv.UvSdkAdditionalData
import com.jetbrains.python.sdk.uv.impl.createUvCli
import com.jetbrains.python.sdk.uv.impl.createUvLowLevel
import java.nio.file.Path
internal class UvPackageRequiresExtractor(private val uvWorkingDirectory: Path?) : PythonPackageRequiresExtractor {
override suspend fun extract(pkg: PythonPackage, module: Module): List<NormalizedPythonPackageName> {
val uvWorkingDirectory = uvWorkingDirectory ?: Path.of(module.basePath!!)
val uv = createUvLowLevel(uvWorkingDirectory, createUvCli())
return uv.listPackageRequirements(pkg).getOr {
thisLogger().info("extracting requires for package ${pkg.name}: error. Output: \n${it.error}")
return emptyList()
}
}
}
private class UvPackageRequiresExtractorProvider: PythonPackageRequiresExtractorProvider {
override fun createExtractor(sdk: Sdk): PythonPackageRequiresExtractor? {
val data = sdk.sdkAdditionalData as? UvSdkAdditionalData ?: return null
return UvPackageRequiresExtractor(data.uvWorkingDirectory)
}
}