mirror of
https://gitflic.ru/project/openide/openide.git
synced 2026-09-27 10:03:11 +07:00
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:
committed by
intellij-monorepo-bot
parent
128fbd3bb0
commit
dcd9383f02
+1
@@ -0,0 +1 @@
|
||||
<warning descr="Requirements file is empty"></warning>
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
[project]
|
||||
dependencies = [
|
||||
"uvicorn[standard]>=0.35.0"
|
||||
]
|
||||
+13
@@ -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"]
|
||||
+1
@@ -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].
|
||||
|
||||
+13
-14
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+14
-4
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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) }
|
||||
|
||||
+2
-3
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+20
-8
@@ -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,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 }
|
||||
}
|
||||
}
|
||||
|
||||
+133
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user