From dcd9383f02e61a262a8a57b7b4c0487adf47cc4f Mon Sep 17 00:00:00 2001 From: TimurMalanin Date: Fri, 20 Mar 2026 16:54:30 +0000 Subject: [PATCH] PY-88127 improve uv inspection: skip optional-deps/dependency-groups, resolve package names from pyproject.toml - Skip [project.optional-dependencies] and [dependency-groups] in NotInstalledRequirementInspection (previously only [build-system] was excluded) - Filter DeclaredButNotInstalledPackagesChecker to only report packages from [project].dependencies, not from optional/dev groups - Resolve workspace member package name from pyproject.toml instead of using module name in uv tree --package - Parse dependency group/extra annotations from uv tree output - Strip extras from package names (e.g. uvicorn[standard] -> uvicorn) (cherry picked from commit 958696becd90026016c1367128f48e4861f5c5ac) IJ-MR-196282 GitOrigin-RevId: 785dc69e03afca7abc0f8d42852b0425e559df01 --- .../EmptyRequirementsFile/requirements.txt | 1 + .../pyproject.toml | 4 + .../pyproject.toml | 13 ++ .../UnsatisfiedRequirement/requirements.txt | 1 + .../python/pyproject/PyProjectToml.kt | 24 ++++ .../DeclaredButNotInstalledPackagesChecker.kt | 27 ++-- .../python/packaging/common/packages.kt | 10 +- .../PythonPackageRequirementsTreeExtractor.kt | 18 ++- .../PyPackagingToolWindowService.kt | 2 +- .../PoetryPackageRequirementsTreeExtractor.kt | 5 +- .../NotInstalledRequirementInspection.kt | 28 ++-- .../sdk/poetry/PoetryCommandExecutor.kt | 15 ++ .../python/sdk/poetry/PoetryPackageManager.kt | 21 ++- python/src/com/jetbrains/python/sdk/uv/Uv.kt | 3 +- .../python/sdk/uv/UvPackageManager.kt | 12 +- .../python/sdk/uv/impl/UvLowLevel.kt | 6 +- .../python/sdk/uv/impl/UvOutputParser.kt | 7 +- .../UvPackageRequirementsTreeExtractor.kt | 10 +- .../UnsatisfiedRequirementInspectionTest.kt | 133 ++++++++++++++++++ .../python/sdk/uv/impl/UvTreeParsingTest.kt | 70 ++++++++- 20 files changed, 351 insertions(+), 59 deletions(-) create mode 100644 python/junit5Tests-framework/testResources/UnsatisfiedRequirementInspectionTest/EmptyRequirementsFile/requirements.txt create mode 100644 python/junit5Tests-framework/testResources/UnsatisfiedRequirementInspectionTest/PyProjectTomlExtrasNotFlagged/pyproject.toml create mode 100644 python/junit5Tests-framework/testResources/UnsatisfiedRequirementInspectionTest/PyProjectTomlUnsatisfiedRequirement/pyproject.toml create mode 100644 python/junit5Tests-framework/testResources/UnsatisfiedRequirementInspectionTest/UnsatisfiedRequirement/requirements.txt create mode 100644 python/testSrc/com/intellij/python/junit5Tests/unit/UnsatisfiedRequirementInspectionTest.kt diff --git a/python/junit5Tests-framework/testResources/UnsatisfiedRequirementInspectionTest/EmptyRequirementsFile/requirements.txt b/python/junit5Tests-framework/testResources/UnsatisfiedRequirementInspectionTest/EmptyRequirementsFile/requirements.txt new file mode 100644 index 000000000000..80811b2507e2 --- /dev/null +++ b/python/junit5Tests-framework/testResources/UnsatisfiedRequirementInspectionTest/EmptyRequirementsFile/requirements.txt @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/python/junit5Tests-framework/testResources/UnsatisfiedRequirementInspectionTest/PyProjectTomlExtrasNotFlagged/pyproject.toml b/python/junit5Tests-framework/testResources/UnsatisfiedRequirementInspectionTest/PyProjectTomlExtrasNotFlagged/pyproject.toml new file mode 100644 index 000000000000..132c578a678a --- /dev/null +++ b/python/junit5Tests-framework/testResources/UnsatisfiedRequirementInspectionTest/PyProjectTomlExtrasNotFlagged/pyproject.toml @@ -0,0 +1,4 @@ +[project] +dependencies = [ + "uvicorn[standard]>=0.35.0" +] diff --git a/python/junit5Tests-framework/testResources/UnsatisfiedRequirementInspectionTest/PyProjectTomlUnsatisfiedRequirement/pyproject.toml b/python/junit5Tests-framework/testResources/UnsatisfiedRequirementInspectionTest/PyProjectTomlUnsatisfiedRequirement/pyproject.toml new file mode 100644 index 000000000000..989e26e75cdf --- /dev/null +++ b/python/junit5Tests-framework/testResources/UnsatisfiedRequirementInspectionTest/PyProjectTomlUnsatisfiedRequirement/pyproject.toml @@ -0,0 +1,13 @@ +[project] +dependencies = [ + "mypy" +] + +[build-system] +requires = ["poetry-core"] + +[project.optional-dependencies] +dev = ["pytest"] + +[dependency-groups] +dev = ["uvicorn[standard]>=0.35.0"] \ No newline at end of file diff --git a/python/junit5Tests-framework/testResources/UnsatisfiedRequirementInspectionTest/UnsatisfiedRequirement/requirements.txt b/python/junit5Tests-framework/testResources/UnsatisfiedRequirementInspectionTest/UnsatisfiedRequirement/requirements.txt new file mode 100644 index 000000000000..5909af2c0242 --- /dev/null +++ b/python/junit5Tests-framework/testResources/UnsatisfiedRequirementInspectionTest/UnsatisfiedRequirement/requirements.txt @@ -0,0 +1 @@ +mypy \ No newline at end of file diff --git a/python/python-pyproject/src/com/intellij/python/pyproject/PyProjectToml.kt b/python/python-pyproject/src/com/intellij/python/pyproject/PyProjectToml.kt index 124be4352360..0fa142a7d01d 100644 --- a/python/python-pyproject/src/com/intellij/python/pyproject/PyProjectToml.kt +++ b/python/python-pyproject/src/com/intellij/python/pyproject/PyProjectToml.kt @@ -1,8 +1,16 @@ // Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. package com.intellij.python.pyproject +import com.intellij.openapi.application.readAction import com.intellij.openapi.module.Module +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Key import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.vfs.readText +import com.intellij.psi.PsiManager +import com.intellij.psi.util.CachedValue +import com.intellij.psi.util.CachedValueProvider +import com.intellij.psi.util.CachedValuesManager import com.jetbrains.python.Result import com.jetbrains.python.sdk.findAmongRoots import kotlinx.coroutines.Dispatchers @@ -33,6 +41,7 @@ const val PY_PROJECT_TOML_DEPENDENCY_GROUPS: String = "dependency-groups" @Internal const val PY_PROJECT_TOML_TOOL_PREFIX: String = "tool" + /** * Represents an issue that could occur in [PyProjectToml.parse]. */ @@ -102,6 +111,21 @@ data class PyProjectToml( } companion object { + private val CACHE_KEY = Key.create>("PyProjectTomlCache") + + /** + * Parses and caches [pyProjectFile] content. Cache is invalidated automatically when the file changes. + */ + @Internal + suspend fun parseCached(project: Project, pyProjectFile: VirtualFile): PyProjectToml? { + return readAction { + val psiFile = PsiManager.getInstance(project).findFile(pyProjectFile) ?: return@readAction null + CachedValuesManager.getManager(project).getCachedValue(psiFile, CACHE_KEY, { + CachedValueProvider.Result.create(parse(pyProjectFile.readText()), pyProjectFile) + }, false) + } + } + /** * TODO: REDOC * Attempts to parse [inputStream] and construct an instance of [PyProjectToml]. diff --git a/python/src/com/jetbrains/python/inspections/requirement/DeclaredButNotInstalledPackagesChecker.kt b/python/src/com/jetbrains/python/inspections/requirement/DeclaredButNotInstalledPackagesChecker.kt index 0bebff012cd1..c0fef5e8bc10 100644 --- a/python/src/com/jetbrains/python/inspections/requirement/DeclaredButNotInstalledPackagesChecker.kt +++ b/python/src/com/jetbrains/python/inspections/requirement/DeclaredButNotInstalledPackagesChecker.kt @@ -3,6 +3,7 @@ package com.jetbrains.python.inspections.requirement import com.intellij.openapi.module.Module import com.intellij.openapi.vfs.VfsUtil +import com.jetbrains.python.packaging.PyPackageName import com.jetbrains.python.packaging.PyRequirement import com.jetbrains.python.packaging.common.PythonPackage import com.jetbrains.python.packaging.common.toRequirements @@ -11,38 +12,36 @@ import com.jetbrains.python.packaging.management.extractDependenciesAsync import com.jetbrains.python.psi.PyUtil class DeclaredButNotInstalledPackagesChecker( - val ignoredPackages: Collection, + ignoredPackages: Collection, ) { + private val ignoredPackageNames: Set = ignoredPackages.mapTo(mutableSetOf()) { PyPackageName.normalizePackageName(it) } + fun findUnsatisfiedRequirements(module: Module, manager: PythonPackageManager): List { val requirements = manager.extractDependenciesAsync() ?: return emptyList() + val packagesToCheck = filterToMainPackages(requirements, manager) val installedPackages = manager.listInstalledPackagesSnapshot() val modulePackages = collectPackagesInModule(module) - return requirements.toRequirements().filter { requirement -> + return packagesToCheck.toRequirements().filter { requirement -> isRequirementUnsatisfied(requirement, installedPackages, modulePackages) } } + private fun filterToMainPackages(packages: List, manager: PythonPackageManager): List { + if (!manager.installedMightBeTransitive) return packages + return packages.filter { it.dependencyGroup == null } + } + private fun isRequirementUnsatisfied( requirement: PyRequirement, installedPackages: List, modulePackages: List, ): Boolean { - if (requirement.name in ignoredPackages) { + if (requirement.name in ignoredPackageNames) { return false } - val isSatisfiedInInstalled = isSatisfied(requirement, installedPackages) - val isSatisfiedInModule = isSatisfied(requirement, modulePackages) - - return !isSatisfiedInInstalled && !isSatisfiedInModule - } - - private fun isSatisfied(requirement: PyRequirement, packages: List): Boolean { - val matchingPackage = packages.find { it.name == requirement.name } ?: return false - return requirement.versionSpecs.isEmpty() || requirement.versionSpecs.all { spec -> - spec.matches(matchingPackage.version) - } + return !(installedPackages + modulePackages).any { it.matches(requirement) } } private fun collectPackagesInModule(module: Module): List { diff --git a/python/src/com/jetbrains/python/packaging/common/packages.kt b/python/src/com/jetbrains/python/packaging/common/packages.kt index 6a26d820cad3..e7e8048cd9df 100644 --- a/python/src/com/jetbrains/python/packaging/common/packages.kt +++ b/python/src/com/jetbrains/python/packaging/common/packages.kt @@ -15,7 +15,12 @@ import org.jetbrains.annotations.Nls import javax.swing.Icon import kotlin.collections.emptyList -open class PythonPackage(name: String, val version: String, val isEditableMode: Boolean) { +@ApiStatus.Internal +@JvmInline +value class PyDependencyGroupName(val name: String) + +@ApiStatus.Internal +open class PythonPackage @JvmOverloads constructor(name: String, val version: String, val isEditableMode: Boolean, val dependencyGroup: PyDependencyGroupName? = null) { companion object { private const val HASH_MULTIPLIER = 31 } @@ -36,13 +41,14 @@ open class PythonPackage(name: String, val version: String, val isEditableMode: override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is PythonPackage) return false - return this@PythonPackage.name == other.name && version == other.version && isEditableMode == other.isEditableMode + return this@PythonPackage.name == other.name && version == other.version && isEditableMode == other.isEditableMode && dependencyGroup == other.dependencyGroup } override fun hashCode(): Int { var result = this@PythonPackage.name.hashCode() result = HASH_MULTIPLIER * result + version.hashCode() result = HASH_MULTIPLIER * result + isEditableMode.hashCode() + result = HASH_MULTIPLIER * result + (dependencyGroup?.hashCode() ?: 0) return result } diff --git a/python/src/com/jetbrains/python/packaging/packageRequirements/PythonPackageRequirementsTreeExtractor.kt b/python/src/com/jetbrains/python/packaging/packageRequirements/PythonPackageRequirementsTreeExtractor.kt index fbb179fcf0a2..38ca5d098be0 100644 --- a/python/src/com/jetbrains/python/packaging/packageRequirements/PythonPackageRequirementsTreeExtractor.kt +++ b/python/src/com/jetbrains/python/packaging/packageRequirements/PythonPackageRequirementsTreeExtractor.kt @@ -25,7 +25,7 @@ interface PythonPackageRequirementsTreeExtractor { PythonPackageRequirementsTreeExtractorProvider.EP_NAME.extensionList .firstNotNullOfOrNull { it.createExtractor(sdk, project) } - fun parseTree(lines: List): PackageNode = treeParser.parseTree(lines) + fun parseTrees(lines: List): List = treeParser.parseTrees(lines) } } @@ -91,9 +91,19 @@ class TreeParser { val nextIndex: Int, ) - fun parseTree(lines: List): PackageNode { - val (node, _) = parseLevel(lines, calculateIndentLevel(lines.first()), 0) - return node + fun parseTrees(lines: List): List { + val nonBlankLines = lines.withIndex().filterNot { it.value.isBlank() } + val result = mutableListOf() + var currentIndex = 0 + + while (currentIndex < nonBlankLines.size) { + val (originalIndex, line) = nonBlankLines[currentIndex] + val (node, nextIndex) = parseLevel(lines, calculateIndentLevel(line), originalIndex) + result.add(node) + currentIndex = nonBlankLines.indexOfFirst { it.index >= nextIndex }.takeIf { it != -1 } ?: nonBlankLines.size + } + + return result } private fun parseLevel(lines: List, startIndent: Int, index: Int): ParseResult { diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingToolWindowService.kt b/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingToolWindowService.kt index 72c283df63e1..8d2915ac9528 100644 --- a/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingToolWindowService.kt +++ b/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingToolWindowService.kt @@ -583,7 +583,7 @@ class PyPackagingToolWindowService(val project: Project, val serviceScope: Corou declaredPackageNames: Set, ): WorkspaceMember { val member = PyWorkspaceMember(memberName) - val packages = tree.children.mapNotNull { node -> + val packages = tree.children.distinctBy { it.name.name }.mapNotNull { node -> val pkg = packageIndex.installedByName[node.name.name] ?: return@mapNotNull null val repository = resolveRepository(context, pkg) val nextVersion = packageIndex.outdated[pkg.name]?.latestVersion?.let { PyPackageVersionNormalizer.normalize(it) } diff --git a/python/src/com/jetbrains/python/poetry/packaging/PoetryPackageRequirementsTreeExtractor.kt b/python/src/com/jetbrains/python/poetry/packaging/PoetryPackageRequirementsTreeExtractor.kt index 8b17850f2450..6cb42aefb8ff 100644 --- a/python/src/com/jetbrains/python/poetry/packaging/PoetryPackageRequirementsTreeExtractor.kt +++ b/python/src/com/jetbrains/python/poetry/packaging/PoetryPackageRequirementsTreeExtractor.kt @@ -8,9 +8,8 @@ import com.jetbrains.python.packaging.packageRequirements.PackageCollectionPacka import com.jetbrains.python.packaging.packageRequirements.PackageNode import com.jetbrains.python.packaging.packageRequirements.PackageStructureNode import com.jetbrains.python.packaging.packageRequirements.PythonPackageRequirementsTreeExtractor -import com.jetbrains.python.packaging.packageRequirements.PythonPackageRequirementsTreeExtractor.Companion.parseTree +import com.jetbrains.python.packaging.packageRequirements.PythonPackageRequirementsTreeExtractor.Companion.parseTrees import com.jetbrains.python.packaging.packageRequirements.PythonPackageRequirementsTreeExtractorProvider -import com.jetbrains.python.packaging.packageRequirements.TreeParser import com.jetbrains.python.sdk.poetry.isPoetry import com.jetbrains.python.sdk.poetry.runPoetryWithSdk @@ -28,7 +27,7 @@ internal class PoetryPackageRequirementsTreeExtractor(private val sdk: Sdk) : Py thisLogger().info("extracting all package trees: error. Output: \n${it.error}") return emptyList() } - return TreeParser.splitIntoPackageGroups(data.lines()).map { parseTree(it) } + return parseTrees(data.lines()) } } diff --git a/python/src/com/jetbrains/python/requirements/inspections/tools/NotInstalledRequirementInspection.kt b/python/src/com/jetbrains/python/requirements/inspections/tools/NotInstalledRequirementInspection.kt index 332a82d92b3a..80c4cd0c0022 100644 --- a/python/src/com/jetbrains/python/requirements/inspections/tools/NotInstalledRequirementInspection.kt +++ b/python/src/com/jetbrains/python/requirements/inspections/tools/NotInstalledRequirementInspection.kt @@ -10,16 +10,20 @@ import com.intellij.openapi.module.ModuleUtilCore import com.intellij.openapi.project.Project import com.intellij.openapi.projectRoots.Sdk import com.intellij.psi.PsiElementVisitor +import com.intellij.psi.PsiFile import com.intellij.psi.impl.source.resolve.FileContextUtil import com.intellij.psi.util.findParentOfType import com.intellij.python.pyproject.PY_PROJECT_TOML_BUILD_SYSTEM import com.jetbrains.python.PyBundle import com.jetbrains.python.PyPsiBundle +import com.intellij.util.concurrency.annotations.RequiresBackgroundThread import com.jetbrains.python.packaging.NonModulePackageName -import com.jetbrains.python.packaging.PyPackage +import com.jetbrains.python.packaging.PyPackageName import com.jetbrains.python.packaging.PyRequirement import com.jetbrains.python.packaging.PyRequirementParser +import com.jetbrains.python.packaging.common.PythonPackage import com.jetbrains.python.packaging.management.PythonPackageManager +import com.jetbrains.python.packaging.management.extractDependenciesAsync import com.jetbrains.python.requirements.RequirementsFile import com.jetbrains.python.requirements.RequirementsInspectionVisitor import com.jetbrains.python.requirements.getPythonSdk @@ -49,9 +53,9 @@ class NotInstalledRequirementInspection : LocalInspectionTool() { val packageManager = PythonPackageManager.forSdk(project, sdk) val installedPackages = packageManager.listInstalledPackagesSnapshot() - .map { PyPackage(it.name, it.version) } + val mainPackageNames = getMainDeclaredPackageNames(packageManager) - val notInstalled = findNotInstalledRequirements(requirementsFile, installedPackages, project) + val notInstalled = findNotInstalledRequirements(requirementsFile, installedPackages, project, mainPackageNames) if (notInstalled.isEmpty()) return val isTomlInjection = isInjectedIntoToml(holder.file) @@ -59,13 +63,13 @@ class NotInstalledRequirementInspection : LocalInspectionTool() { } } - private fun isInBuildSystemToml(psiFile: com.intellij.psi.PsiFile): Boolean { + private fun isInBuildSystemToml(psiFile: PsiFile): Boolean { val injectedElement = psiFile.getUserData(FileContextUtil.INJECTED_IN_ELEMENT) ?: return false val tableName = injectedElement.element?.findParentOfType()?.header?.key?.text return tableName == PY_PROJECT_TOML_BUILD_SYSTEM } - private fun handleEmptyFile(psiFile: com.intellij.psi.PsiFile, holder: ProblemsHolder): Boolean { + private fun handleEmptyFile(psiFile: PsiFile, holder: ProblemsHolder): Boolean { if (psiFile.text.isNullOrBlank()) { val fixes = ModuleUtilCore.findModuleForPsiElement(psiFile) ?.let { arrayOf(PyGenerateRequirementsFileQuickFix(it)) } @@ -81,21 +85,29 @@ class NotInstalledRequirementInspection : LocalInspectionTool() { return false } + @RequiresBackgroundThread + private fun getMainDeclaredPackageNames(manager: PythonPackageManager): Set? { + val packages = manager.extractDependenciesAsync() ?: return null + return packages.filter { it.dependencyGroup == null }.mapTo(mutableSetOf()) { PyPackageName.from(it.name) } + } + private fun findNotInstalledRequirements( requirementsFile: RequirementsFile, - installedPackages: List, + installedPackages: List, project: Project, + mainPackageNames: Set?, ): List> { return requirementsFile.requirements() .mapNotNull { req -> val parsed = PyRequirementParser.fromLine(req.text) ?: return@mapNotNull null NonModulePackageName.create(parsed.name, project) ?: return@mapNotNull null - if (parsed.match(installedPackages) != null) return@mapNotNull null + if (mainPackageNames != null && PyPackageName.from(parsed.name) !in mainPackageNames) return@mapNotNull null + if (installedPackages.any { it.matches(parsed) }) return@mapNotNull null req to parsed } } - private fun isInjectedIntoToml(file: com.intellij.psi.PsiFile): Boolean { + private fun isInjectedIntoToml(file: PsiFile): Boolean { return file.getUserData(FileContextUtil.INJECTED_IN_ELEMENT) != null } diff --git a/python/src/com/jetbrains/python/sdk/poetry/PoetryCommandExecutor.kt b/python/src/com/jetbrains/python/sdk/poetry/PoetryCommandExecutor.kt index 6be5e49fd98f..294273a48bed 100644 --- a/python/src/com/jetbrains/python/sdk/poetry/PoetryCommandExecutor.kt +++ b/python/src/com/jetbrains/python/sdk/poetry/PoetryCommandExecutor.kt @@ -23,6 +23,7 @@ import com.jetbrains.python.packaging.PyRequirement import com.jetbrains.python.packaging.PyRequirementParser import com.jetbrains.python.packaging.common.PythonOutdatedPackage import com.jetbrains.python.packaging.common.PythonPackage +import com.jetbrains.python.packaging.packageRequirements.TreeParser import com.jetbrains.python.sdk.ToolCommandExecutor import com.jetbrains.python.sdk.associatedModulePath import com.jetbrains.python.sdk.runTool @@ -188,6 +189,20 @@ fun parsePoetryShow(input: String): List { return result } +@Internal +fun parsePoetryShowTree(input: String): List { + val result = mutableListOf() + for (line in input.lines()) { + if (line.isBlank()) continue + if (!TreeParser.isRootLine(line)) continue + val packageInfo = line.split(" ").filter { it.isNotBlank() && it != "(!)" } + if (packageInfo.size >= 2) { + result.add(PythonPackage(packageInfo[0], packageInfo[1], false)) + } + } + return result +} + @Internal suspend fun poetryShowOutdated(sdk: Sdk): PyResult> { val output = runPoetryWithSdk(sdk, "show", "--all", "--outdated").getOr { return it } diff --git a/python/src/com/jetbrains/python/sdk/poetry/PoetryPackageManager.kt b/python/src/com/jetbrains/python/sdk/poetry/PoetryPackageManager.kt index c1242389e401..a411a15bd1b1 100644 --- a/python/src/com/jetbrains/python/sdk/poetry/PoetryPackageManager.kt +++ b/python/src/com/jetbrains/python/sdk/poetry/PoetryPackageManager.kt @@ -9,6 +9,7 @@ import com.jetbrains.python.PyBundle import com.jetbrains.python.errorProcessing.PyResult import com.jetbrains.python.packaging.PyPackageName import com.jetbrains.python.packaging.PyRequirement +import com.jetbrains.python.packaging.common.PyDependencyGroupName import com.jetbrains.python.packaging.common.PythonOutdatedPackage import com.jetbrains.python.packaging.common.PythonPackage import com.jetbrains.python.packaging.common.PythonRepositoryPackageSpecification @@ -78,16 +79,22 @@ class PoetryPackageManager(project: Project, sdk: Sdk) : PythonPackageManager(pr } override suspend fun extractDependencies(): PyResult> { - val output = runPoetryWithSdk(sdk, "show", "--top-level") + val allOutput = runPoetryWithSdk(sdk, "show", "--top-level") .getOr { return it } - if (output.isBlank()) { - return PyResult.success(emptyList()) + val allPackages = if (allOutput.isBlank()) emptyList() else parsePoetryShow(allOutput) + + val mainOutput = runPoetryWithSdk(sdk, "show", "--only", "main", "--tree") + .getOr { return PyResult.success(allPackages) } + val mainNames = parsePoetryShowTree(mainOutput).mapTo(mutableSetOf()) { it.name } + + val annotated = allPackages.map { pkg -> + if (pkg.name in mainNames) pkg + else PythonPackage(pkg.name, pkg.version, pkg.isEditableMode, NON_MAIN_DEPENDENCY_GROUP) } - return PyResult.success(parsePoetryShow(output)) + return PyResult.success(annotated) } - /** * Categorizes packages into standalone packages and pyproject.toml declared packages. */ @@ -183,6 +190,10 @@ class PoetryPackageManager(project: Project, sdk: Sdk) : PythonPackageManager(pr poetryInstallPackage(sdk, listOf(requirement.presentableText), emptyList()).getOr { return false } return true } + + companion object { + private val NON_MAIN_DEPENDENCY_GROUP = PyDependencyGroupName("dev") + } } /** diff --git a/python/src/com/jetbrains/python/sdk/uv/Uv.kt b/python/src/com/jetbrains/python/sdk/uv/Uv.kt index 8cacc503c911..ec8c6f59ca48 100644 --- a/python/src/com/jetbrains/python/sdk/uv/Uv.kt +++ b/python/src/com/jetbrains/python/sdk/uv/Uv.kt @@ -1,7 +1,6 @@ // 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.jetbrains.python.errorProcessing.PyResult import com.jetbrains.python.packaging.PyPackageName import com.jetbrains.python.packaging.common.PythonOutdatedPackage @@ -39,7 +38,7 @@ interface UvLowLevel

{ suspend fun listPackages(): PyResult> suspend fun listOutdatedPackages(): PyResult> - suspend fun listTopLevelPackages(module: Module): PyResult> + suspend fun listTopLevelPackages(packageName: PyWorkspaceMember): PyResult> suspend fun listPackageRequirements(name: PythonPackage): PyResult> suspend fun listPackageRequirementsTree(name: PythonPackage): PyResult suspend fun listProjectStructureTree(): PyResult diff --git a/python/src/com/jetbrains/python/sdk/uv/UvPackageManager.kt b/python/src/com/jetbrains/python/sdk/uv/UvPackageManager.kt index 319ab245cbe4..aa6d92fee261 100644 --- a/python/src/com/jetbrains/python/sdk/uv/UvPackageManager.kt +++ b/python/src/com/jetbrains/python/sdk/uv/UvPackageManager.kt @@ -8,6 +8,7 @@ import com.intellij.openapi.project.Project import com.intellij.openapi.project.modules import com.intellij.openapi.projectRoots.Sdk import com.intellij.openapi.vfs.VirtualFile +import com.intellij.python.pyproject.PyProjectToml import com.intellij.util.cancelOnDispose import com.jetbrains.python.PyBundle.message import com.jetbrains.python.Result @@ -50,7 +51,8 @@ internal class UvPackageManager(project: Project, sdk: Sdk, uvExecutionContextDe override suspend fun installPackageCommand(installRequest: PythonPackageInstallRequest, options: List, module: Module?): PyResult { return withUv { uv -> if (module != null) { - uv.addDependency(installRequest, emptyList(), PyWorkspaceMember(module.name)) + val packageName = resolvePackageName(module) + uv.addDependency(installRequest, emptyList(), PyWorkspaceMember(packageName)) } else if (sdk.uvUsePackageManagement) { uv.installPackage(installRequest, emptyList()) @@ -105,7 +107,8 @@ internal class UvPackageManager(project: Project, sdk: Sdk, uvExecutionContextDe val allPackages = mutableSetOf() var lastFailure: PyResult>? = null for (module in modules) { - val result = withUv { uv -> uv.listTopLevelPackages(module) } + val packageName = resolvePackageName(module) + val result = withUv { uv -> uv.listTopLevelPackages(PyWorkspaceMember(packageName)) } when (result) { is Result.Success -> allPackages.addAll(result.result) is Result.Failure -> lastFailure = result @@ -183,6 +186,11 @@ internal class UvPackageManager(project: Project, sdk: Sdk, uvExecutionContextDe } } + private suspend fun resolvePackageName(module: Module): String { + val pyProjectFile = PyProjectToml.findFile(module) ?: return module.name + return PyProjectToml.parseCached(module.project, pyProjectFile)?.project?.name ?: module.name + } + // TODO PY-87712 Double check for remotes override fun getDependencyFile(): VirtualFile? { val uvWorkingDirectory = runBlockingMaybeCancellable { uvExecutionContextDeferred.await().workingDir } 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 25d54ef0f4c8..2bad4b31a60a 100644 --- a/python/src/com/jetbrains/python/sdk/uv/impl/UvLowLevel.kt +++ b/python/src/com/jetbrains/python/sdk/uv/impl/UvLowLevel.kt @@ -8,7 +8,6 @@ import com.fasterxml.jackson.module.kotlin.readValue import com.intellij.execution.target.TargetProgressIndicator import com.intellij.execution.target.value.constant import com.intellij.execution.target.value.getRelativeTargetPath -import com.intellij.openapi.module.Module import com.intellij.platform.eel.provider.localEel import com.jetbrains.python.PyBundle import com.jetbrains.python.errorProcessing.ExecError @@ -165,9 +164,8 @@ private class UvLowLevelImpl

(private val cwd: Path, private val } } - override suspend fun listTopLevelPackages(module: Module): PyResult> { - val args = mutableListOf("tree", "--depth=1", "--frozen", "--package", module.name) - val out = uvCli.runUv(cwd, venvPath, false, *args.toTypedArray()) + override suspend fun listTopLevelPackages(packageName: PyWorkspaceMember): PyResult> { + val out = uvCli.runUv(cwd, venvPath, false, "tree", "--depth=1", "--frozen", "--package", packageName.name) .getOr { return it } return PyExecResult.success(UvOutputParser.parseUvPackageList(out)) diff --git a/python/src/com/jetbrains/python/sdk/uv/impl/UvOutputParser.kt b/python/src/com/jetbrains/python/sdk/uv/impl/UvOutputParser.kt index e0d8099a7980..e5a3b6dbd060 100644 --- a/python/src/com/jetbrains/python/sdk/uv/impl/UvOutputParser.kt +++ b/python/src/com/jetbrains/python/sdk/uv/impl/UvOutputParser.kt @@ -2,6 +2,7 @@ package com.jetbrains.python.sdk.uv.impl import com.jetbrains.python.packaging.PyPackageName +import com.jetbrains.python.packaging.common.PyDependencyGroupName import com.jetbrains.python.packaging.common.PythonPackage import com.jetbrains.python.packaging.packageRequirements.TreeParser import com.jetbrains.python.venvReader.tryResolvePath @@ -10,6 +11,7 @@ import kotlin.io.path.exists object UvOutputParser { private val WHITESPACE_REGEX = Regex("\\s+") + private val GROUP_REGEX = Regex("""\((?:group|extra):\s*(\w+)\)""") private const val REQUIRES_LINE_PREFIX = "Requires:" fun parseUvPackageList(input: String): List { @@ -19,9 +21,10 @@ object UvOutputParser { if (TreeParser.isRootLine(line)) break val parts = line.trim().split(WHITESPACE_REGEX).drop(1) if (parts.isEmpty()) continue - val packageName = parts[0] + val packageName = parts[0].substringBefore('[') val version = parts.getOrElse(1) { "" }.removePrefix("v") - packageList.add(PythonPackage(packageName, version, false)) + val group = GROUP_REGEX.find(line)?.groupValues?.get(1)?.let { PyDependencyGroupName(it) } + packageList.add(PythonPackage(packageName, version, false, group)) } return packageList } diff --git a/python/src/com/jetbrains/python/uv/packaging/UvPackageRequirementsTreeExtractor.kt b/python/src/com/jetbrains/python/uv/packaging/UvPackageRequirementsTreeExtractor.kt index 01d63611c750..c927e159d6e9 100644 --- a/python/src/com/jetbrains/python/uv/packaging/UvPackageRequirementsTreeExtractor.kt +++ b/python/src/com/jetbrains/python/uv/packaging/UvPackageRequirementsTreeExtractor.kt @@ -12,8 +12,7 @@ import com.jetbrains.python.packaging.packageRequirements.PackageCollectionPacka import com.jetbrains.python.packaging.packageRequirements.PackageNode import com.jetbrains.python.packaging.packageRequirements.PackageStructureNode import com.jetbrains.python.packaging.packageRequirements.PythonPackageRequirementsTreeExtractor -import com.jetbrains.python.packaging.packageRequirements.PythonPackageRequirementsTreeExtractor.Companion.parseTree -import com.jetbrains.python.packaging.packageRequirements.TreeParser +import com.jetbrains.python.packaging.packageRequirements.PythonPackageRequirementsTreeExtractor.Companion.parseTrees import com.jetbrains.python.packaging.packageRequirements.PythonPackageRequirementsTreeExtractorProvider import com.jetbrains.python.packaging.packageRequirements.WorkspaceMemberPackageStructureNode import com.jetbrains.python.getOrNull @@ -40,7 +39,7 @@ internal class UvPackageRequirementsTreeExtractor(private val sdk: Sdk, private val output = uv.listPackageRequirementsTree(PythonPackage(packageName, "", false)).getOr { return createLeafNode(packageName) } - return parseTree(output.lines()) + return parseTrees(output.lines()).firstOrNull() ?: createLeafNode(packageName) } /** @@ -53,7 +52,8 @@ internal class UvPackageRequirementsTreeExtractor(private val sdk: Sdk, private ): List { val output = uv.listProjectStructureTree().getOrNull() ?: return declaredPackageNames.map { extractPackageTree(uv, it) } - val projectRoot = parseTree(output.lines()) + val projectRoot = parseTrees(output.lines()).firstOrNull() + ?: return declaredPackageNames.map { extractPackageTree(uv, it) } val childrenByName = projectRoot.children.associateBy { it.name.name } return declaredPackageNames.map { name -> childrenByName[name] ?: createLeafNode(name) } } @@ -126,7 +126,7 @@ internal class UvPackageRequirementsTreeExtractor(private val sdk: Sdk, private private suspend fun extractUndeclaredPackages(uv: UvLowLevel<*>, declaredPackageNames: Set): List { val output = uv.listAllPackagesTree().getOrNull() ?: return emptyList() - return TreeParser.splitIntoPackageGroups(output.lines()).map { parseTree(it) } + return parseTrees(output.lines()) .filter { it.name.name !in declaredPackageNames } } } diff --git a/python/testSrc/com/intellij/python/junit5Tests/unit/UnsatisfiedRequirementInspectionTest.kt b/python/testSrc/com/intellij/python/junit5Tests/unit/UnsatisfiedRequirementInspectionTest.kt new file mode 100644 index 000000000000..0bf337e62a28 --- /dev/null +++ b/python/testSrc/com/intellij/python/junit5Tests/unit/UnsatisfiedRequirementInspectionTest.kt @@ -0,0 +1,133 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.python.junit5Tests.unit + +import com.intellij.codeInspection.ex.InspectionProfileImpl +import com.intellij.lang.annotation.HighlightSeverity +import com.intellij.openapi.project.Project +import com.intellij.openapi.project.modules +import com.intellij.python.junit5Tests.framework.env.PyEnvTestCase +import com.intellij.python.junit5Tests.framework.env.pySdkFixture +import com.intellij.python.pyproject.PY_PROJECT_TOML +import com.intellij.python.test.env.junit5.pyUvVenvFixture +import com.intellij.testFramework.ExtensionTestUtil +import com.intellij.testFramework.IndexingTestUtil +import com.intellij.testFramework.TestApplicationManager +import com.intellij.testFramework.TestDataPath +import com.intellij.testFramework.TestDataProvider +import com.intellij.testFramework.fixtures.CodeInsightTestFixture +import com.intellij.testFramework.fixtures.IdeaProjectTestFixture +import com.intellij.testFramework.fixtures.impl.CodeInsightTestFixtureImpl +import com.intellij.testFramework.fixtures.impl.TempDirTestFixtureImpl +import com.intellij.testFramework.junit5.fixture.TestFixture +import com.intellij.testFramework.junit5.fixture.disposableFixture +import com.intellij.testFramework.junit5.fixture.moduleFixture +import com.intellij.testFramework.junit5.fixture.projectFixture +import com.intellij.testFramework.junit5.fixture.tempPathFixture +import com.intellij.testFramework.junit5.fixture.testFixture +import com.intellij.python.community.helpersLocator.PythonHelpersLocator +import com.jetbrains.python.packaging.common.PythonPackage +import com.jetbrains.python.packaging.management.PythonPackageManagerProvider +import com.jetbrains.python.packaging.management.TestPackageManagerProvider +import com.jetbrains.python.requirements.inspections.tools.NotInstalledRequirementInspection +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.nio.file.Path + +@PyEnvTestCase +@TestDataPath("\$CONTENT_ROOT/../junit5Tests-framework/testResources/UnsatisfiedRequirementInspectionTest") +class UnsatisfiedRequirementInspectionTest { + + private val testDisposable by disposableFixture() + private val tempDir = tempPathFixture() + private val project = projectFixture(tempDir, openAfterCreation = true) + private val module = project.moduleFixture(tempDir, addPathToSourceRoot = true) + + @Suppress("unused") + private val venvFixture = pySdkFixture().pyUvVenvFixture( + addToSdkTable = true, + moduleFixture = module, + ) + + private val fixture = pyCodeInsightFixture(project, tempDir) + + @BeforeEach + fun setUp() { + InspectionProfileImpl.INIT_INSPECTIONS = true + IndexingTestUtil.waitUntilIndexesAreReady(project.get()) + fixture.get().enableInspections(NotInstalledRequirementInspection::class.java) + } + + @AfterEach + fun tearDown() { + InspectionProfileImpl.INIT_INSPECTIONS = false + } + + @Test + fun testUnsatisfiedRequirement() { + val f = fixture.get() + f.copyDirectoryToProject("UnsatisfiedRequirement", "") + f.configureFromTempProjectFile("requirements.txt") + f.checkHighlighting(true, false, true, false) + assertTrue(f.availableIntentions.any { it.text == "Install package mypy" }) + } + + @Test + fun testPyProjectTomlExtrasNotFlagged() { + val provider = TestPackageManagerProvider() + .withPackageInstalled(PythonPackage("uvicorn", "0.35.0", false)) + ExtensionTestUtil.maskExtensions(PythonPackageManagerProvider.EP_NAME, listOf(provider), testDisposable) + + val f = fixture.get() + f.copyDirectoryToProject("PyProjectTomlExtrasNotFlagged", "") + f.configureFromTempProjectFile(PY_PROJECT_TOML) + f.checkHighlighting(true, false, true, false) + val warnings = f.doHighlighting(HighlightSeverity.WARNING) + assertTrue( + warnings.none { it.description?.contains("uvicorn") == true }, + "uvicorn[standard]>=0.35.0 should not be flagged when uvicorn is installed", + ) + } + + @Test + fun testEmptyRequirementsFile() { + val f = fixture.get() + f.copyDirectoryToProject("EmptyRequirementsFile", "") + f.configureFromTempProjectFile("requirements.txt") + f.checkHighlighting(true, false, true, false) + assertTrue(f.availableIntentions.any { it.text == "Add imported packages to requirements\u2026" }) + } +} + +private fun pyCodeInsightFixture( + projectFixture: TestFixture, + tempDirFixture: TestFixture, +): TestFixture = testFixture { + val project = projectFixture.init() + val tempDir = tempDirFixture.init() + + val ideaProjectFixture = object : IdeaProjectTestFixture { + override fun getProject(): Project = project + override fun getModule() = project.modules[0] + override fun setUp() { + TestApplicationManager.getInstance().setDataProvider(TestDataProvider(project)) + } + + override fun tearDown() { + TestApplicationManager.getInstance().setDataProvider(null) + } + } + val ideaTempDirFixture = object : TempDirTestFixtureImpl() { + override fun doCreateTempDirectory(): Path = tempDir + override fun deleteOnTearDown(): Boolean = false + } + + val codeInsightFixture = CodeInsightTestFixtureImpl(ideaProjectFixture, ideaTempDirFixture) + codeInsightFixture.testDataPath = PythonHelpersLocator.getPythonCommunityPath().resolve("junit5Tests-framework/testResources/UnsatisfiedRequirementInspectionTest").toString() + codeInsightFixture.setUp() + initialized(codeInsightFixture) { + codeInsightFixture.tearDown() + } +} diff --git a/python/testSrc/com/jetbrains/python/sdk/uv/impl/UvTreeParsingTest.kt b/python/testSrc/com/jetbrains/python/sdk/uv/impl/UvTreeParsingTest.kt index 491484671bd4..05bbd2b92f94 100644 --- a/python/testSrc/com/jetbrains/python/sdk/uv/impl/UvTreeParsingTest.kt +++ b/python/testSrc/com/jetbrains/python/sdk/uv/impl/UvTreeParsingTest.kt @@ -1,7 +1,8 @@ // 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.impl -import com.jetbrains.python.packaging.packageRequirements.PythonPackageRequirementsTreeExtractor.Companion.parseTree +import com.jetbrains.python.packaging.common.PyDependencyGroupName +import com.jetbrains.python.packaging.packageRequirements.PythonPackageRequirementsTreeExtractor.Companion.parseTrees import com.jetbrains.python.packaging.packageRequirements.TreeParser import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Nested @@ -143,6 +144,61 @@ class UvTreeParsingTest { assertThat(packages[0].version).isEqualTo("2.0.0") } + @Test + fun `dependency groups are parsed from group annotation`() { + val input = """ + myapp v1.0.0 + ├── requests v2.31.0 + ├── pytest v8.0.0 (group: dev) + ├── ruff v0.15.2 (group: lint) + └── ty v0.0.18 (group: lint) + """.trimIndent() + + val packages = UvOutputParser.parseUvPackageList(input) + + assertThat(packages).hasSize(4) + val byName = packages.associateBy { it.name } + assertThat(byName["requests"]!!.dependencyGroup).isNull() + assertThat(byName["pytest"]!!.dependencyGroup).isEqualTo(PyDependencyGroupName("dev")) + assertThat(byName["ruff"]!!.dependencyGroup).isEqualTo(PyDependencyGroupName("lint")) + assertThat(byName["ty"]!!.dependencyGroup).isEqualTo(PyDependencyGroupName("lint")) + } + + @Test + fun `dependency groups are parsed from extra annotation`() { + val input = """ + myapp v1.0.0 + ├── fastapi v0.129.2 + ├── pytest-cov v7.0.0 (extra: dev) + ├── ruff v0.15.2 (extra: lint) + └── ty v0.0.18 (extra: lint) + """.trimIndent() + + val packages = UvOutputParser.parseUvPackageList(input) + + assertThat(packages).hasSize(4) + val byName = packages.associateBy { it.name } + assertThat(byName["fastapi"]!!.dependencyGroup).isNull() + assertThat(byName["pytest-cov"]!!.dependencyGroup).isEqualTo(PyDependencyGroupName("dev")) + assertThat(byName["ruff"]!!.dependencyGroup).isEqualTo(PyDependencyGroupName("lint")) + assertThat(byName["ty"]!!.dependencyGroup).isEqualTo(PyDependencyGroupName("lint")) + } + + @Test + fun `extras are stripped from package names`() { + val input = """ + myapp v1.0.0 + ├── uvicorn[standard] v0.41.0 + └── boto3[crt] v1.35.0 + """.trimIndent() + + val packages = UvOutputParser.parseUvPackageList(input) + + assertThat(packages).hasSize(2) + assertThat(packages.map { it.name }).containsExactly("uvicorn", "boto3") + assertThat(packages.map { it.version }).containsExactly("0.41.0", "1.35.0") + } + @Test fun `trailing blank lines are ignored`() { val input = "myapp v1.0.0\n├── requests v2.31.0\n\n\n" @@ -161,7 +217,7 @@ class UvTreeParsingTest { fun `single root with no children`() { val lines = listOf("requests v2.31.0") - val tree = parseTree(lines) + val tree = parseTrees(lines).first() assertThat(tree.name.name).isEqualTo("requests") assertThat(tree.children).isEmpty() @@ -174,7 +230,7 @@ class UvTreeParsingTest { "├── urllib3 v2.1.0", ) - val tree = parseTree(lines) + val tree = parseTrees(lines).first() assertThat(tree.name.name).isEqualTo("requests") assertThat(tree.children).hasSize(1) @@ -191,7 +247,7 @@ class UvTreeParsingTest { "└── urllib3 v2.1.0", ) - val tree = parseTree(lines) + val tree = parseTrees(lines).first() assertThat(tree.name.name).isEqualTo("requests") assertThat(tree.children).hasSize(4) @@ -209,7 +265,7 @@ class UvTreeParsingTest { " └── markupsafe v2.1.5", ) - val tree = parseTree(lines) + val tree = parseTrees(lines).first() assertThat(tree.name.name).isEqualTo("flask") assertThat(tree.children).hasSize(2) @@ -234,7 +290,7 @@ class UvTreeParsingTest { " └── c v1.0.0", ) - val tree = parseTree(lines) + val tree = parseTrees(lines).first() assertThat(tree.name.name).isEqualTo("app") assertThat(tree.children).hasSize(1) @@ -252,7 +308,7 @@ class UvTreeParsingTest { "└── requests v2.31.0", ) - val tree = parseTree(lines) + val tree = parseTrees(lines).first() assertThat(tree.children).hasSize(2) assertThat(tree.children[0].name.name).isEqualTo("pytest")