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