> {
- 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")