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
This commit is contained in:
TimurMalanin
2026-03-21 16:59:52 +00:00
committed by intellij-monorepo-bot
parent 128fbd3bb0
commit dcd9383f02
20 changed files with 351 additions and 59 deletions
@@ -0,0 +1 @@
<warning descr="Requirements file is empty"></warning>
@@ -0,0 +1,4 @@
[project]
dependencies = [
"uvicorn[standard]>=0.35.0"
]
@@ -0,0 +1,13 @@
[project]
dependencies = [
"<warning descr="Package mypy is not installed">mypy</warning>"
]
[build-system]
requires = ["poetry-core"]
[project.optional-dependencies]
dev = ["pytest"]
[dependency-groups]
dev = ["uvicorn[standard]>=0.35.0"]
@@ -0,0 +1 @@
<warning descr="Package mypy is not installed">mypy</warning>
@@ -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<CachedValue<PyProjectToml>>("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].
@@ -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<String>,
ignoredPackages: Collection<String>,
) {
private val ignoredPackageNames: Set<String> = ignoredPackages.mapTo(mutableSetOf()) { PyPackageName.normalizePackageName(it) }
fun findUnsatisfiedRequirements(module: Module, manager: PythonPackageManager): List<PyRequirement> {
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<PythonPackage>, manager: PythonPackageManager): List<PythonPackage> {
if (!manager.installedMightBeTransitive) return packages
return packages.filter { it.dependencyGroup == null }
}
private fun isRequirementUnsatisfied(
requirement: PyRequirement,
installedPackages: List<PythonPackage>,
modulePackages: List<PythonPackage>,
): 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<PythonPackage>): 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<PythonPackage> {
@@ -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
}
@@ -25,7 +25,7 @@ interface PythonPackageRequirementsTreeExtractor {
PythonPackageRequirementsTreeExtractorProvider.EP_NAME.extensionList
.firstNotNullOfOrNull { it.createExtractor(sdk, project) }
fun parseTree(lines: List<String>): PackageNode = treeParser.parseTree(lines)
fun parseTrees(lines: List<String>): List<PackageNode> = treeParser.parseTrees(lines)
}
}
@@ -91,9 +91,19 @@ class TreeParser {
val nextIndex: Int,
)
fun parseTree(lines: List<String>): PackageNode {
val (node, _) = parseLevel(lines, calculateIndentLevel(lines.first()), 0)
return node
fun parseTrees(lines: List<String>): List<PackageNode> {
val nonBlankLines = lines.withIndex().filterNot { it.value.isBlank() }
val result = mutableListOf<PackageNode>()
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<String>, startIndent: Int, index: Int): ParseResult {
@@ -583,7 +583,7 @@ class PyPackagingToolWindowService(val project: Project, val serviceScope: Corou
declaredPackageNames: Set<String>,
): 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) }
@@ -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())
}
}
@@ -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<TomlTable>()?.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<PyPackageName>? {
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<PyPackage>,
installedPackages: List<PythonPackage>,
project: Project,
mainPackageNames: Set<PyPackageName>?,
): List<Pair<Requirement, PyRequirement>> {
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
}
@@ -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<PythonPackage> {
return result
}
@Internal
fun parsePoetryShowTree(input: String): List<PythonPackage> {
val result = mutableListOf<PythonPackage>()
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<Map<String, PythonOutdatedPackage>> {
val output = runPoetryWithSdk(sdk, "show", "--all", "--outdated").getOr { return it }
@@ -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<List<PythonPackage>> {
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")
}
}
/**
+1 -2
View File
@@ -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<P : PathHolder> {
suspend fun listPackages(): PyResult<List<PythonPackage>>
suspend fun listOutdatedPackages(): PyResult<List<PythonOutdatedPackage>>
suspend fun listTopLevelPackages(module: Module): PyResult<List<PythonPackage>>
suspend fun listTopLevelPackages(packageName: PyWorkspaceMember): PyResult<List<PythonPackage>>
suspend fun listPackageRequirements(name: PythonPackage): PyResult<List<PyPackageName>>
suspend fun listPackageRequirementsTree(name: PythonPackage): PyResult<String>
suspend fun listProjectStructureTree(): PyResult<String>
@@ -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<String>, module: Module?): PyResult<Unit> {
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<PythonPackage>()
var lastFailure: PyResult<List<PythonPackage>>? = 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 }
@@ -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<P : PathHolder>(private val cwd: Path, private val
}
}
override suspend fun listTopLevelPackages(module: Module): PyResult<List<PythonPackage>> {
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<List<PythonPackage>> {
val out = uvCli.runUv(cwd, venvPath, false, "tree", "--depth=1", "--frozen", "--package", packageName.name)
.getOr { return it }
return PyExecResult.success(UvOutputParser.parseUvPackageList(out))
@@ -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<PythonPackage> {
@@ -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
}
@@ -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<PackageNode> {
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<String>): List<PackageNode> {
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 }
}
}
@@ -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<Project>,
tempDirFixture: TestFixture<Path>,
): TestFixture<CodeInsightTestFixture> = 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()
}
}
@@ -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")