PY-90397 Inspect dependency files by package-manager tracking, not SDK flavor

DependenciesInspection gated requirements.txt on a VirtualEnvSdkFlavor check,
so package inspections never ran for WSL/SSH interpreters. Gate on
PythonPackageManager.tracksDependencyFile instead (the cached dependency-file
tree), resolving the injection host for pyproject.toml/environment.yml. Conda
provider switched off the isCondaVirtualEnv flavor check the same way.


(cherry picked from commit 6163f0deec314b7a5bc2b23429ffc791230bd139)

IJ-MR-209334

GitOrigin-RevId: f671fa32dea95e95fb34401fcea56d15dc98c237
This commit is contained in:
Vitaly Legchilkin
2026-06-19 18:26:52 +00:00
committed by intellij-monorepo-bot
parent 3691e96113
commit d87367f4c1
10 changed files with 131 additions and 64 deletions
@@ -2,14 +2,12 @@
package com.jetbrains.python.conda
import com.intellij.codeInspection.util.InspectionMessage
import com.intellij.openapi.projectRoots.Sdk
import com.intellij.psi.PsiElement
import com.intellij.python.community.impl.conda.PyCondaBundle
import com.intellij.python.community.impl.conda.environmentYml.CondaEnvironmentYmlSdkUtils.envFileNames
import com.intellij.python.community.impl.conda.environmentYml.format.CondaEnvironmentYmlParser
import com.jetbrains.python.inspections.dependencies.DependenciesInspectionProvider
import com.jetbrains.python.inspections.dependencies.DependenciesMap
import com.jetbrains.python.isCondaVirtualEnv
import com.jetbrains.python.packaging.PyRequirement
import org.jetbrains.yaml.psi.YAMLFile
import org.jetbrains.yaml.psi.YAMLMapping
@@ -17,8 +15,8 @@ import org.jetbrains.yaml.psi.YAMLScalar
import org.jetbrains.yaml.psi.YAMLSequence
internal class CondaDependenciesInspectionProvider : DependenciesInspectionProvider<YAMLFile>(YAMLFile::class.java) {
override fun provideDependencies(file: YAMLFile, sdk: Sdk): DependenciesMap? {
if (file.name !in envFileNames || !sdk.isCondaVirtualEnv) {
override fun provideDependencies(file: YAMLFile): DependenciesMap? {
if (file.name !in envFileNames) {
return null
}
@@ -6,9 +6,10 @@ import com.intellij.codeInspection.LocalInspectionToolSession
import com.intellij.codeInspection.LocalQuickFix
import com.intellij.codeInspection.ProblemHighlightType
import com.intellij.codeInspection.ProblemsHolder
import com.intellij.openapi.projectRoots.Sdk
import com.intellij.psi.HintedPsiElementVisitor
import com.intellij.psi.PsiElement
import com.intellij.psi.PsiElementVisitor
import com.intellij.psi.PsiFile
import com.jetbrains.python.PyBundle
import com.jetbrains.python.conda.ExportDependenciesQuickFix
import com.jetbrains.python.inspections.quickfix.UpdateLockedDependenciesQuickFix
@@ -36,70 +37,68 @@ import org.jetbrains.annotations.ApiStatus
* - A requirement names a package that **is installed but outdated** (a newer version is
* available in the configured repository) → "Requirement <name>=<v>, latest is <v>", with
* quick fixes [UpdateRequirementQuickFix] and [UpdateAllRequirementQuickFix].
*
* An inspection for a specific PSI file should be specified via [DependenciesInspectionProvider]
* extension point. For an example, refer to
*
* This is conceptually a file-level inspection: it analyses a whole dependency file (or an
* injected requirements fragment) against the active interpreter. The visitor is therefore
* driven only for the file element (see [HintedPsiElementVisitor]) and resolves the SDK and
* package manager once, instead of re-resolving them for every PSI element.
*
* An inspection for a specific PSI file should be specified via [DependenciesInspectionProvider]
* extension point. For an example, refer to
* [com.jetbrains.python.requirements.inspections.tools.RequirementsDependenciesInspectionProvider].
*/
@ApiStatus.Internal
class DependenciesInspection : LocalInspectionTool() {
override fun buildVisitor(
holder: ProblemsHolder,
isnTheFly: Boolean,
isOnTheFly: Boolean,
session: LocalInspectionToolSession,
): PsiElementVisitor = object : PsiElementVisitor() {
): PsiElementVisitor = object : PsiElementVisitor(), HintedPsiElementVisitor {
override fun getHintPsiElements(): List<Class<*>> = DependenciesInspectionProviderData.providers.map { it.`class` }
override fun visitElement(element: PsiElement) {
super.visitElement(element)
val psiFile = element as? PsiFile ?: return
val sdk = getPythonSdk(psiFile)?.takeIf { !it.isReadOnly } ?: return
val packageManager = PythonPackageManager.forSdk(psiFile.project, sdk)
val psiFile = session.file
val sdk = getPythonSdk(psiFile) ?: return
var dependencies: DependenciesMap? = null
lateinit var provider: DependenciesInspectionProvider<*>
for (entry in DependenciesInspectionProviderData.providers) {
provider = entry
entry.getDependencies(element, sdk)?.also {
dependencies = it
break
}
}
if (dependencies == null) {
// The dependency file this PSI belongs to: for an injected requirements fragment (pyproject.toml's
// [project].dependencies, environment.yml's pip section) that's the host file; otherwise the file
// itself. Inspect only when the interpreter's package manager actually tracks that file in its
// cached dependency-file tree (the root plus, e.g., uv workspace members), not merely a file that
// shares the name. The cache is seeded only after installed packages load, so this also means the
// data we compare against is ready.
val dependencyFile = psiFile.injectionParent()?.containingFile ?: psiFile
if (!packageManager.tracksDependencyFile(dependencyFile)) {
return
}
val packageManager =
sdk
.takeIf { !it.isReadOnly }
?.let { PythonPackageManager.forSdk(psiFile.project, it) }
?.takeIf { it.isInstalledPackagesLoaded }
?: return
val isInjection = psiFile.injectionParent() != null
val (provider, dependencies) = DependenciesInspectionProviderData.providers.firstNotNullOfOrNull { provider ->
provider.getDependencies(psiFile)?.let { provider to it }
} ?: return
if (!isInjection) {
verifyNonEmptyFile(provider, packageManager)
if (psiFile.injectionParent() == null) {
verifyNonEmptyFile(psiFile, provider, packageManager)
}
packageManager.verifyPackageManager(dependencies, sdk, packageManager)
packageManager.verifyPackageManager(dependencies)
}
fun verifyNonEmptyFile(provider: DependenciesInspectionProvider<*>, packageManager: PythonPackageManager) {
if (!session.file.text.isNullOrBlank()) {
private fun verifyNonEmptyFile(psiFile: PsiFile, provider: DependenciesInspectionProvider<*>, packageManager: PythonPackageManager) {
if (!psiFile.text.isNullOrBlank()) {
return
}
val dependenciesExporter = packageManager.dependenciesExporter ?: return
val emptyFileInspectionMessage = provider.emptyFileInspectionMessage
holder.registerProblem(
session.file,
emptyFileInspectionMessage,
psiFile,
provider.emptyFileInspectionMessage,
ProblemHighlightType.GENERIC_ERROR_OR_WARNING,
ExportDependenciesQuickFix(dependenciesExporter),
)
}
fun PythonPackageManager.verifyPackageManager(dependencyMap: DependenciesMap, sdk: Sdk, packageManager: PythonPackageManager) {
private fun PythonPackageManager.verifyPackageManager(dependencyMap: DependenciesMap) {
val installedPackages = listInstalledPackagesSnapshot()
val outdatedPackages = listOutdatedPackagesSnapshot()
@@ -126,14 +125,12 @@ class DependenciesInspection : LocalInspectionTool() {
}
}
populateNotInstalledProblems(notInstalled, sdk, packageManager, updateLockedAction() != null)
populateNotInstalledProblems(notInstalled, updateLockedAction() != null)
populateOutdatedProblems(outdated)
}
private fun populateNotInstalledProblems(
private fun PythonPackageManager.populateNotInstalledProblems(
notInstalled: List<Pair<PyRequirement, PsiElement>>,
sdk: Sdk,
packageManager: PythonPackageManager,
useUpdateLockFix: Boolean,
) {
if (notInstalled.isEmpty()) {
@@ -142,7 +139,7 @@ class DependenciesInspection : LocalInspectionTool() {
val updateLockedDependenciesQuickFix =
if (useUpdateLockFix) {
UpdateLockedDependenciesQuickFix(sdk, packageManager)
UpdateLockedDependenciesQuickFix(this)
}
else {
null
@@ -3,7 +3,6 @@ package com.jetbrains.python.inspections.dependencies
import com.intellij.codeInspection.util.InspectionMessage
import com.intellij.openapi.extensions.ExtensionPointName
import com.intellij.openapi.projectRoots.Sdk
import com.intellij.psi.PsiElement
import com.intellij.psi.PsiFile
import com.jetbrains.python.packaging.PyRequirement
@@ -12,13 +11,13 @@ import org.jetbrains.annotations.ApiStatus
typealias DependenciesMap = Map<PyRequirement, PsiElement>
@ApiStatus.Internal
abstract class DependenciesInspectionProvider<T : PsiFile>(private val `class`: Class<T>) {
protected abstract fun provideDependencies(file: T, sdk: Sdk): DependenciesMap?
abstract class DependenciesInspectionProvider<T : PsiFile>(internal val `class`: Class<T>) {
protected abstract fun provideDependencies(file: T): DependenciesMap?
abstract val emptyFileInspectionMessage: @InspectionMessage String
fun getDependencies(file: PsiElement, sdk: Sdk): DependenciesMap? =
fun getDependencies(file: PsiFile): DependenciesMap? =
@Suppress("UNCHECKED_CAST")
if (`class`.isInstance(file)) provideDependencies(file as T, sdk) else null
if (`class`.isInstance(file)) provideDependencies(file as T) else null
}
internal object DependenciesInspectionProviderData {
@@ -32,7 +32,6 @@ import com.jetbrains.python.requirements.getPythonSdk
* the specific package manager satisfies that intent.
*/
internal class UpdateLockedDependenciesQuickFix(
private val sdk: Sdk,
private val packageManager: PythonPackageManager,
) : LocalQuickFix, PriorityAction {
override fun getFamilyName(): String = PyBundle.message("QFIX.NAME.install.all.requirements")
@@ -46,7 +45,7 @@ internal class UpdateLockedDependenciesQuickFix(
// Route through PythonPackageManagerUI so the run gets the standard serialized background-progress
// wrapper plus error-sink reporting; otherwise a sync failure (e.g. `poetry lock` is
// out of sync) would surface only via logger.warn and the user would see no feedback.
val pmUI = PythonPackageManagerUI.forSdk(project, sdk)
val pmUI = PythonPackageManagerUI.forSdk(project, packageManager.sdk)
pmUI.executeCommand(PyBundle.message("python.packaging.installing.packages")) {
updateLockedAction().mapSuccess { }
}
@@ -234,6 +234,18 @@ abstract class PythonPackageManager @ApiStatus.Internal constructor(
@ApiStatus.Experimental
fun listDeclaredPackagesSnapshot(): List<PythonPackage>? = dependencyCache.snapshot.value
/**
* Whether [file] is one of the dependency files this manager currently tracks for the active
* interpreter — i.e. it is present in the cached dependency-file tree (the root plus, e.g., uv
* workspace members), not merely a file that happens to share a name. Non-blocking: reflects the
* last cache refresh and is `false` until the manager has been initialized.
*/
@ApiStatus.Internal
fun tracksDependencyFile(file: PsiFile): Boolean {
val virtualFile = file.originalFile.virtualFile ?: return false
return dependencyCache.trackedFilesSnapshot().any { it.virtualFile == virtualFile }
}
@ApiStatus.Experimental
suspend fun listOutdatedPackages(): Map<String, PythonOutdatedPackage> {
waitForInit()
@@ -518,6 +530,13 @@ internal abstract suspend fun installPackageCommand(
/** Refreshes if needed and awaits the entry's deferred; `await()` starts the LAZY async on first call. */
suspend fun awaitLatest(): PyResult<List<PythonPackage>>? = ensureFreshEntry().deferred.await()
/**
* Non-blocking view of the dependency files the latest cache entry tracks (root plus, e.g., uv
* workspace members). Empty until the cache has been seeded by [initInstalledPackages] or a
* [listDeclaredPackagesCached] refresh.
*/
fun trackedFilesSnapshot(): List<PyDependenciesFile> = entry?.files?.keys?.toList().orEmpty()
/**
* [files] is the `(file -> modification stamp)` cache key; [deferred] is the
* `listDeclaredPackages` result (pre-completed `CompletableDeferred(null)` when there
@@ -2,14 +2,12 @@
package com.jetbrains.python.requirements.inspections.tools
import com.intellij.codeInspection.util.InspectionMessage
import com.intellij.openapi.projectRoots.Sdk
import com.intellij.psi.PsiElement
import com.intellij.psi.util.findParentOfType
import com.intellij.python.pyproject.PY_PROJECT_TOML_PROJECT
import com.jetbrains.python.PyPsiBundle
import com.jetbrains.python.inspections.dependencies.DependenciesInspectionProvider
import com.jetbrains.python.inspections.dependencies.DependenciesMap
import com.jetbrains.python.isNonToolVirtualEnv
import com.jetbrains.python.packaging.PyRequirement
import com.jetbrains.python.packaging.PyRequirementParser
import com.jetbrains.python.psi.injectionParent
@@ -45,17 +43,13 @@ private fun PsiElement.isInUninspectedTomlSection(): Boolean {
* project interpreter.
*/
internal class RequirementsDependenciesInspectionProvider : DependenciesInspectionProvider<RequirementsFile>(RequirementsFile::class.java) {
override fun provideDependencies(file: RequirementsFile, sdk: Sdk): DependenciesMap? {
override fun provideDependencies(file: RequirementsFile): DependenciesMap? {
val injectionParent = file.injectionParent()
if (injectionParent != null && injectionParent.isInUninspectedTomlSection()) {
return null
}
if (injectionParent == null && !sdk.isNonToolVirtualEnv) {
return null
}
val requirements = file.requirements()
val dependenciesMap = mutableMapOf<PyRequirement, PsiElement>()
@@ -2,14 +2,20 @@
package com.intellij.python.junit5Tests.env.conda
import com.intellij.codeInspection.ex.InspectionProfileImpl
import com.intellij.openapi.application.ApplicationManager
import com.intellij.testFramework.TestDataPath
import com.jetbrains.python.PyBundle
import com.jetbrains.python.inspections.dependencies.DependenciesInspection
import com.jetbrains.python.packaging.common.PythonOutdatedPackage
import com.jetbrains.python.packaging.common.PythonPackage
import com.jetbrains.python.packaging.management.PythonPackageManager
import com.jetbrains.python.packaging.management.TestPackageManagerProvider
import com.jetbrains.python.packaging.management.RequirementsProviderType
import com.jetbrains.python.packaging.management.TestPythonPackageManager
import com.jetbrains.python.requirements.PythonDependencyTestCase
import com.jetbrains.python.sdk.PythonSdkAdditionalData
import com.jetbrains.python.sdk.pythonSdk
import kotlinx.coroutines.runBlocking
@TestDataPath($$"$CONTENT_ROOT/../testData/conda/environmentYml/inspections")
class CondaDependenciesInspectionProviderTest : PythonDependencyTestCase() {
@@ -30,6 +36,7 @@ class CondaDependenciesInspectionProviderTest : PythonDependencyTestCase() {
initTestPackageManager(provider)
myFixture.copyDirectoryToProject(getTestName(false), "")
markEnvironmentYmlAsDependencyRoot()
myFixture.configureFromTempProjectFile("environment.yml")
myFixture.enableInspections(DependenciesInspection::class.java)
@@ -53,4 +60,23 @@ class CondaDependenciesInspectionProviderTest : PythonDependencyTestCase() {
}
override fun getBasePath(): String = "/community/python/testData/conda/environmentYml/inspections/"
/**
* Marks the SDK as tracking `environment.yml` as its declared-dependencies root (associating it with
* the module dir that holds the file) so the package manager's dependency-file cache tracks it — the
* gate in [com.jetbrains.python.inspections.dependencies.DependenciesInspection] reads that cache. The
* real conda manager exposes `environment.yml` as its root unconditionally; the test manager keys this
* off SDK user data. Init is then forced so the cache is seeded before highlighting runs.
*/
private fun markEnvironmentYmlAsDependencyRoot() {
val sdk = myFixture.project.pythonSdk!!
sdk.putUserData(TestPythonPackageManager.REQUIREMENTS_PROVIDER_KEY, RequirementsProviderType.ENVIRONMENT_YML)
val moduleDir = myFixture.findFileInTempDir("environment.yml").parent
ApplicationManager.getApplication().runWriteAction {
val modificator = sdk.sdkModificator
(modificator.sdkAdditionalData as PythonSdkAdditionalData).associatedModulePath = moduleDir.path
modificator.commitChanges()
}
runBlocking { PythonPackageManager.forSdk(myFixture.project, sdk).waitForInit() }
}
}
@@ -32,8 +32,11 @@ import com.intellij.testFramework.junit5.fixture.testFixture
import com.jetbrains.python.inspections.dependencies.DependenciesInspection
import com.jetbrains.python.packaging.common.PythonOutdatedPackage
import com.jetbrains.python.packaging.common.PythonPackage
import com.jetbrains.python.packaging.management.PythonPackageManager
import com.jetbrains.python.packaging.management.PythonPackageManagerProvider
import com.jetbrains.python.packaging.management.RequirementsProviderType
import com.jetbrains.python.packaging.management.TestPackageManagerProvider
import com.jetbrains.python.packaging.management.TestPythonPackageManager
import com.jetbrains.python.packaging.management.TestPythonPackageManagerService
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
@@ -41,6 +44,7 @@ import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Timeout
import kotlinx.coroutines.runBlocking
import java.nio.file.Path as NioPath
import kotlin.io.path.ExperimentalPathApi
import kotlin.io.path.copyToRecursively
@@ -62,7 +66,6 @@ internal class RequirementsDependenciesInspectionProviderTest {
private val projectFixture = projectFixture(tempPathFixture, openAfterCreation = true)
private val moduleFixture = projectFixture.moduleFixture(tempPathFixture, addPathToSourceRoot = true)
@Suppress("unused")
private val venvFixture = pySdkFixture().pyVenvFixture(
where = tempPathFixture,
addToSdkTable = true,
@@ -73,6 +76,7 @@ internal class RequirementsDependenciesInspectionProviderTest {
private val project by projectFixture
private val fixture by codeInsightFixture
private val sdk by venvFixture
private lateinit var testDataDir: NioPath
@@ -97,6 +101,7 @@ internal class RequirementsDependenciesInspectionProviderTest {
@Test
fun unsatisfiedRequirement() {
setupPackageManager()
trackDependencyRoot(RequirementsProviderType.REQUIREMENTS_TXT)
fixture.configureFromTempProjectFile("requirements.txt")
fixture.checkHighlighting(true, false, true, false)
assertTrue(fixture.availableIntentions.any { it.text == "Install package mypy" })
@@ -105,6 +110,7 @@ internal class RequirementsDependenciesInspectionProviderTest {
@Test
fun pyProjectTomlUnsatisfiedRequirement() {
setupPackageManager()
trackDependencyRoot(RequirementsProviderType.PYPROJECT_TOML)
fixture.configureFromTempProjectFile(PY_PROJECT_TOML)
fixture.checkHighlighting(true, false, true, false)
val warnings = fixture.doHighlighting(HighlightSeverity.WARNING)
@@ -129,6 +135,7 @@ internal class RequirementsDependenciesInspectionProviderTest {
TestPythonPackageManagerService.replacePythonPackageManagerServiceWithTestInstance(
project, listOf(PythonPackage("uvicorn", "0.35.0", false))
)
trackDependencyRoot(RequirementsProviderType.PYPROJECT_TOML)
fixture.configureFromTempProjectFile(PY_PROJECT_TOML)
val warnings = fixture.doHighlighting(HighlightSeverity.WARNING)
assertTrue(
@@ -140,9 +147,10 @@ internal class RequirementsDependenciesInspectionProviderTest {
@Test
fun emptyRequirementsFile() {
setupPackageManager()
trackDependencyRoot(RequirementsProviderType.REQUIREMENTS_TXT)
fixture.configureFromTempProjectFile("requirements.txt")
fixture.checkHighlighting(true, false, true, false)
assertTrue(fixture.availableIntentions.any { it.text == "Add imported packages to requirements…" })
assertTrue(fixture.availableIntentions.any { it.text == "Export packages" })
}
@Test
@@ -159,6 +167,7 @@ internal class RequirementsDependenciesInspectionProviderTest {
PythonOutdatedPackage("flask", "1.0", "3.0.0"),
)
)
trackDependencyRoot(RequirementsProviderType.PYPROJECT_TOML)
fixture.configureFromTempProjectFile(PY_PROJECT_TOML)
fixture.checkHighlighting(true, false, true, false)
}
@@ -170,6 +179,19 @@ internal class RequirementsDependenciesInspectionProviderTest {
fixture.testRootDisposable,
)
}
/**
* Makes the venv SDK's package manager track the given dependency file (requirements.txt or
* pyproject.toml) in its cached dependency-file tree — the gate in
* [com.jetbrains.python.inspections.dependencies.DependenciesInspection] reads that cache. Real
* package managers expose their root unconditionally; the test manager keys this off SDK user data.
* Init is then forced so the cache is seeded before highlighting runs (production drives this via the
* package UI / sync / FUS paths).
*/
private fun trackDependencyRoot(type: RequirementsProviderType) {
sdk.putUserData(TestPythonPackageManager.REQUIREMENTS_PROVIDER_KEY, type)
runBlocking { PythonPackageManager.forSdk(project, sdk).waitForInit() }
}
}
private fun pyCodeInsightFixture(
@@ -7,5 +7,5 @@ import org.jetbrains.annotations.VisibleForTesting
@ApiStatus.Internal
@VisibleForTesting
enum class RequirementsProviderType {
REQUIREMENTS_TXT, SETUP_PY, ENVIRONMENT_YML
REQUIREMENTS_TXT, SETUP_PY, ENVIRONMENT_YML, PYPROJECT_TOML
}
@@ -7,6 +7,7 @@ import com.intellij.openapi.project.Project
import com.intellij.openapi.projectRoots.Sdk
import com.intellij.openapi.util.Key
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.psi.PsiFile
import com.intellij.psi.PsiManager
import com.intellij.python.community.impl.conda.environmentYml.format.CondaEnvironmentYmlParser
import com.jetbrains.python.errorProcessing.PyResult
@@ -39,6 +40,12 @@ internal class TestPythonPackageManager(project: Project, sdk: Sdk) : PythonPack
.withPackageDetails(packageDetails)
.withRepoPackagesVersions(packageVersions)
// Non-null so the empty-dependency-file inspection can offer its "export dependencies" quick fix in tests.
override val dependenciesExporter: DependenciesExporter =
object : DependenciesExporter {
override fun export(file: PsiFile) {}
}
override suspend fun loadOutdatedPackagesCommand(): PyResult<List<PythonOutdatedPackage>> {
return PyResult.success(emptyList())
}
@@ -95,6 +102,7 @@ internal class TestPythonPackageManager(project: Project, sdk: Sdk) : PythonPack
RequirementsProviderType.REQUIREMENTS_TXT -> Path.of("requirements.txt")
RequirementsProviderType.SETUP_PY -> Path.of("setup.py")
RequirementsProviderType.ENVIRONMENT_YML -> Path.of("environment.yml")
RequirementsProviderType.PYPROJECT_TOML -> Path.of("pyproject.toml")
}
)
}
@@ -116,6 +124,11 @@ internal class TestPythonPackageManager(project: Project, sdk: Sdk) : PythonPack
val environmentYmlFile = moduleDir.findChild("environment.yml") ?: return null
extractFromEnvironmentYml(environmentYmlFile)
}
RequirementsProviderType.PYPROJECT_TOML -> {
// The dependencies inspection parses pyproject.toml's [project].dependencies via its own
// injection-aware provider, so the manager only needs to expose the file as tracked here.
PyResult.success(emptyList())
}
}
}