From a54f30720226c08d0689282953289ddc79441e0d Mon Sep 17 00:00:00 2001 From: Timur Malanin Date: Tue, 20 May 2025 17:23:55 +0000 Subject: [PATCH] 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 --- .../intellij.python.community.impl.xml | 7 +++ .../python/packaging/common/packages.kt | 12 +++- .../PythonPackageRequiresExtractor.kt | 30 ++++++++++ .../PoetryPythonPackageRequiresExtractor.kt | 60 +++++++++++++++++++ python/src/com/jetbrains/python/sdk/uv/Uv.kt | 4 +- .../python/sdk/uv/impl/UvLowLevel.kt | 25 +++++++- .../UvPythonPackageRequiresExtractor.kt | 33 ++++++++++ 7 files changed, 168 insertions(+), 3 deletions(-) create mode 100644 python/src/com/jetbrains/python/packaging/packageRequires/PythonPackageRequiresExtractor.kt create mode 100644 python/src/com/jetbrains/python/poetry/packaging/PoetryPythonPackageRequiresExtractor.kt create mode 100644 python/src/com/jetbrains/python/uv/packaging/UvPythonPackageRequiresExtractor.kt diff --git a/python/pluginResources/intellij.python.community.impl.xml b/python/pluginResources/intellij.python.community.impl.xml index fbcde3d9dffb..1e26de2775b2 100644 --- a/python/pluginResources/intellij.python.community.impl.xml +++ b/python/pluginResources/intellij.python.community.impl.xml @@ -778,6 +778,10 @@ + + @@ -880,6 +884,9 @@ + + + diff --git a/python/src/com/jetbrains/python/packaging/common/packages.kt b/python/src/com/jetbrains/python/packaging/common/packages.kt index 5e1c6d5b8f86..4c079e2d794c 100644 --- a/python/src/com/jetbrains/python/packaging/common/packages.kt +++ b/python/src/com/jetbrains/python/packaging/common/packages.kt @@ -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 { diff --git a/python/src/com/jetbrains/python/packaging/packageRequires/PythonPackageRequiresExtractor.kt b/python/src/com/jetbrains/python/packaging/packageRequires/PythonPackageRequiresExtractor.kt new file mode 100644 index 000000000000..2fe94348c225 --- /dev/null +++ b/python/src/com/jetbrains/python/packaging/packageRequires/PythonPackageRequiresExtractor.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.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 + + 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 = ExtensionPointName.create("Pythonid.PythonPackageRequiresExtractorProvider") + } +} \ No newline at end of file diff --git a/python/src/com/jetbrains/python/poetry/packaging/PoetryPythonPackageRequiresExtractor.kt b/python/src/com/jetbrains/python/poetry/packaging/PoetryPythonPackageRequiresExtractor.kt new file mode 100644 index 000000000000..6dd54adbd0c4 --- /dev/null +++ b/python/src/com/jetbrains/python/poetry/packaging/PoetryPythonPackageRequiresExtractor.kt @@ -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 { + 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): List { + val packages = mutableListOf() + + 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) + } +} \ No newline at end of file diff --git a/python/src/com/jetbrains/python/sdk/uv/Uv.kt b/python/src/com/jetbrains/python/sdk/uv/Uv.kt index 840e2d53a709..e75a84486c4a 100644 --- a/python/src/com/jetbrains/python/sdk/uv/Uv.kt +++ b/python/src/com/jetbrains/python/sdk/uv/Uv.kt @@ -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> suspend fun listOutdatedPackages(): PyResult> + suspend fun listPackageRequirements(name: PythonPackage): PyResult> suspend fun isProjectSynced(inexact: Boolean): PyExecResult suspend fun isScriptSynced(inexact: Boolean, scriptPath: Path): PyExecResult 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 4544028ecc70..d94a2064c670 100644 --- a/python/src/com/jetbrains/python/sdk/uv/impl/UvLowLevel.kt +++ b/python/src/com/jetbrains/python/sdk/uv/impl/UvLowLevel.kt @@ -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> { + 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): PyExecResult { 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 { return uvCli.runUv(cwd, "sync") - } + } override suspend fun lock(): PyExecResult { return uvCli.runUv(cwd, "lock") } + + private fun parsePackageRequirements(input: String): List { + 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 { diff --git a/python/src/com/jetbrains/python/uv/packaging/UvPythonPackageRequiresExtractor.kt b/python/src/com/jetbrains/python/uv/packaging/UvPythonPackageRequiresExtractor.kt new file mode 100644 index 000000000000..335c7cae5fd1 --- /dev/null +++ b/python/src/com/jetbrains/python/uv/packaging/UvPythonPackageRequiresExtractor.kt @@ -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 { + 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) + } +} \ No newline at end of file