PY-89838 PY-90501 PY-91037 PY-91040 PY-91041 PY-91067 PY-91077: Python Packages tool window redesign follow-ups and bug fixes

(cherry picked from commit 27f8b75e400e50f5e90f5d9ecf14604bcd5367bf)

IJ-MR-214946

GitOrigin-RevId: 3f0bf18406ffb35aca82fc986681b8196669671b
This commit is contained in:
TimurMalanin
2026-07-28 15:27:22 +00:00
committed by intellij-monorepo-bot
parent 2dc2b46a66
commit 187a8a8341
24 changed files with 509 additions and 120 deletions
@@ -164,7 +164,6 @@
<pluginSuggestionProvider implementation="com.jetbrains.python.suggestions.PythonDapPluginSuggestionProvider"/>
<postStartupActivity implementation="com.jetbrains.python.poetry.PoetryPyProjectTomlPostStartupActivity"/>
<postStartupActivity implementation="com.jetbrains.python.psi.resolve.PyPartialStubMarkersInvalidatorActivity"/>
<postStartupActivity implementation="com.jetbrains.python.packaging.toolwindow.PyPackagesToolWindowDefaultAnchorMigration"/>
<postStartupActivity implementation="com.jetbrains.python.debugger.PyDebuggerBackendToolWindowSetup"/>
<virtualFileCustomDataProvider implementation="com.jetbrains.python.psi.PyLangLevelVirtualFileCustomDataProvider"/>
@@ -755,7 +754,7 @@
<notificationGroup id="Python source root detection" displayType="STICKY_BALLOON" isLogByDefault="true" bundle="messages.PyBundle"
key="python.source.root.detection.confirm.notification.group"/>
<toolWindow id="Python Packages" anchor="right" secondary="true"
<toolWindow id="Python Packages" anchor="bottom" secondary="true"
icon="com.jetbrains.python.icons.PythonIcons.Python.PythonPackages"
factoryClass="com.jetbrains.python.packaging.toolwindow.PyPackagesToolWindowFactory"/>
<toolWindowExtractorMode id="Python Packages" mode="mirror"/>
@@ -1624,7 +1624,7 @@ debugger.dap.plugin.install.got.it.header=Try the Python DAP Debugger plugin
debugger.dap.plugin.install.got.it.text=Install the Python DAP Debugger plugin for faster and more reliable Python debugging with debugpy
action.PyInstallPackage.text=Install
action.PyInstallPackageAction.text=Advanced package install
action.PyInstallPackageAction.text=Advanced Package Install\u2026
separator.python.packaging.settings=Settings
action.PyInstallFromDiskPackage.text=Install From Disk\u2026
action.InstallFromVcsPackageAction.text=Install From VCS\u2026
@@ -6,7 +6,6 @@ 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
@@ -131,7 +130,7 @@ data class 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)
CachedValueProvider.Result.create(parse(psiFile.text), psiFile)
}, false)
}
}
@@ -21,8 +21,8 @@ internal class CondaRepositoryManger(override val project: Project, val sdk: Sdk
override val builtInRepositories: List<PyPackageRepository>
get() = listOf(CondaPackageRepository) + pipRepositoryManger.builtInRepositories
override val repositories: List<PyPackageRepository>
get() = listOf(CondaPackageRepository) + pipRepositoryManger.repositories
override val allRepositories: List<PyPackageRepository>
get() = listOf(CondaPackageRepository) + pipRepositoryManger.allRepositories
private val condaPackageCache = service<CondaPackageCache>()
@@ -18,7 +18,23 @@ import org.jetbrains.annotations.CheckReturnValue
internal interface PythonRepositoryManager {
val project: Project
val repositories: List<PyPackageRepository>
/**
* The raw list of repositories owned by this manager, including any the user has disabled in
* Settings. Implementations override this; consumers should normally use [repositories]
* (below) instead, which filters out disabled entries so search / package-existence checks
* automatically respect the user's opt-out. Only touch [allRepositories] directly when the
* UX genuinely needs to expose or manipulate disabled repositories (e.g. the repository
* settings editor).
*/
val allRepositories: List<PyPackageRepository>
/**
* Every enabled repository from [allRepositories]. Filtered centrally so individual query
* paths (search, `hasPackageSnapshot`, …) can't forget to skip disabled entries — an easy
* mistake to make and the reason PY-91041 shipped in the first place.
*/
val repositories: List<PyPackageRepository> get() = allRepositories.filter { it.enabled }
/**
* Built-in repositories that are always shown in the repository settings (e.g. PyPI, Conda).
@@ -24,7 +24,7 @@ import java.time.Duration
@Service(Service.Level.PROJECT)
internal class PipRepositoryManager(override val project: Project) : PythonRepositoryManagerBase() {
override val repositories: List<PyPackageRepository>
override val allRepositories: List<PyPackageRepository>
get() = listOf(PyPiPackageRepository) + service<PythonSimpleRepositoryCacheService>().repositories
private val packageDetailsCache = Caffeine.newBuilder()
@@ -1,43 +0,0 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.jetbrains.python.packaging.toolwindow
import com.intellij.ide.util.PropertiesComponent
import com.intellij.openapi.project.Project
import com.intellij.openapi.startup.ProjectActivity
import com.intellij.openapi.wm.ToolWindowAnchor
import com.intellij.toolWindow.ToolWindowDefaultLayoutManager
private const val MIGRATION_FLAG_KEY = "python.packages.toolwindow.default.anchor.migrated.v2"
private const val TOOL_WINDOW_ID = "Python Packages"
/**
* The Python Packages tool window now defaults to the **right** stripe (alongside AI Assistant /
* Database), but the app-level [ToolWindowDefaultLayoutManager] caches whatever stripe the user
* was last on across projects. When the branch shipped earlier intermediate registrations on the
* `bottom` and `left` stripes, that cache picked them up — and every freshly-created project then
* inherited the wrong stripe, no matter what the plugin XML or [PyToolWindowLayoutProvider]
* declare.
*
* This activity runs once per IDE install: if the active default layout has the Python Packages
* window pinned to anything other than RIGHT, force it back to RIGHT and re-save. Existing
* per-project [workspace.xml] entries still win (they are read before the default layout), so a
* project where the user has explicitly moved the tool window will keep its custom position; only
* new projects (and projects without saved layout for this id) are affected.
*/
internal class PyPackagesToolWindowDefaultAnchorMigration : ProjectActivity {
override suspend fun execute(project: Project) {
val properties = PropertiesComponent.getInstance()
if (properties.getBoolean(MIGRATION_FLAG_KEY)) return
val manager = ToolWindowDefaultLayoutManager.getInstance()
val layout = manager.getLayoutCopy()
val info = layout.getInfo(TOOL_WINDOW_ID)
if (info != null && (info.anchor != ToolWindowAnchor.RIGHT || !info.isSplit)) {
info.anchor = ToolWindowAnchor.RIGHT
info.isSplit = true
info.sideWeight = 0.5f
manager.setLayout(layout)
}
properties.setValue(MIGRATION_FLAG_KEY, true)
}
}
@@ -4,7 +4,11 @@ package com.jetbrains.python.packaging.toolwindow
import com.intellij.openapi.Disposable
import com.intellij.openapi.actionSystem.ActionManager
import com.intellij.openapi.actionSystem.ActionGroup
import com.intellij.openapi.actionSystem.ActionPlaces
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.actionSystem.DataSink
import com.intellij.openapi.actionSystem.DefaultActionGroup
import com.intellij.openapi.project.DumbAwareAction
import com.intellij.openapi.application.EDT
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.openapi.components.service
@@ -18,8 +22,11 @@ import com.intellij.openapi.wm.ToolWindowAnchor
import com.intellij.openapi.wm.ToolWindowManager
import com.intellij.openapi.wm.ex.ToolWindowManagerListener
import com.intellij.openapi.wm.impl.content.ToolWindowContentUi
import com.intellij.ui.components.fields.ExtendableTextComponent
import com.intellij.ui.OnePixelSplitter
import com.intellij.ui.dsl.builder.AlignX
import com.intellij.ui.dsl.builder.panel
import com.intellij.ui.dsl.gridLayout.UnscaledGaps
import com.intellij.ui.dsl.gridLayout.UnscaledGapsY
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import com.jetbrains.python.PyBundle.message
@@ -37,6 +44,7 @@ import org.intellij.lang.annotations.Language
import org.jetbrains.annotations.ApiStatus
import org.jetbrains.annotations.Nls
import java.awt.BorderLayout
import java.awt.Cursor
import java.awt.KeyboardFocusManager
import javax.swing.JComponent
import javax.swing.JPanel
@@ -174,21 +182,39 @@ internal class PyPackagingToolWindowPanel(private val project: Project) : Simple
}
private fun createSearchBar(): JComponent {
packageSearchController.addExtension(
ExtendableTextComponent.Extension.create(
PyPackageIcons.AddPackage,
message("action.PyInstallPackageAction.text"),
Runnable {
PyInstallPackageDialog(project).show(packageSearchController.text.trim().takeIf { it.isNotEmpty() })
}
)
)
val bundledAction = ActionManager.getInstance().getAction("PyInstallPackageAction")
val installAction = object : DumbAwareAction(
message("action.PyInstallPackageAction.text"),
null,
PyPackageIcons.AddPackage,
) {
override fun actionPerformed(e: AnActionEvent) {
PyInstallPackageDialog(project).show(packageSearchController.text.trim().takeIf { it.isNotEmpty() })
}
}.apply {
bundledAction?.shortcutSet?.let { shortcutSet = it }
}
val toolbar = ActionManager.getInstance().createActionToolbar(
ActionPlaces.TOOLWINDOW_CONTENT,
DefaultActionGroup(installAction),
true,
).apply {
setReservePlaceAutoPopupIcon(false)
component.border = JBUI.Borders.empty()
component.cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
}
val hPad = UIUtil.getListCellHPadding()
val vPad = UIUtil.getListCellVPadding()
return JPanel(BorderLayout()).apply {
border = JBUI.Borders.empty(vPad, hPad)
add(packageSearchController, BorderLayout.CENTER)
val searchBarPanel = panel {
row {
cell(packageSearchController).align(AlignX.FILL).resizableColumn().customize(UnscaledGaps.EMPTY)
cell(toolbar.component).customize(UnscaledGaps.EMPTY)
}.customize(UnscaledGapsY.EMPTY)
}.apply {
border = JBUI.Borders.empty(vPad, hPad, vPad, hPad)
}
toolbar.targetComponent = searchBarPanel
return searchBarPanel
}
private fun trackModules() {
@@ -260,11 +260,10 @@ internal class PyPackagingTreeView(
private fun createNewRepository(repository: PyPackageRepository, items: List<DisplayablePackage>, moreItems: Int, sortedAll: List<DisplayablePackage>) {
val newTree = PyPackagesTree(project, controller)
newTree.items = items
if (sortedAll.size > items.size) newTree.primeSortedMatches(sortedAll)
newTree.pendingMore = moreItems
val newTreeGroup = PyPackagingTreeGroup(repository, newTree, container, showHeader = true, useTreeNodeHeader = true)
newTreeGroup.items = items
if (sortedAll.size > items.size) newTree.primeSortedMatches(sortedAll)
newTree.pendingMore = moreItems
repositories.add(newTreeGroup)
newTreeGroup.addTo(uninstalledContainerPanel)
newTree.addTreeSelectionListener {
@@ -25,7 +25,9 @@ import com.jetbrains.python.packaging.utils.PyPackageCoroutine
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import com.intellij.psi.PsiElement
import com.intellij.psi.PsiErrorElement
import com.intellij.psi.PsiFile
import com.intellij.psi.util.PsiTreeUtil
import com.intellij.ui.JBColor
import com.intellij.ui.dsl.builder.panel
import com.intellij.util.ui.JBUI
@@ -68,9 +70,24 @@ internal class PyDependencyGroupInlayHintsProvider : InlayHintsProvider<NoSettin
val module = ModuleUtilCore.findModuleForFile(file) ?: return null
val sdk = PythonSdkUtil.findPythonSdk(module) ?: return null
if (!isDependencyGroupSupported(sdk)) return null
if (hasParseErrors(file)) return null
return Collector(editor)
}
companion object {
/**
* Whether [file] carries any TOML parse errors. The inlay resolver runs on segments that
* survive the TOML parser's error recovery (a bare `test` line under `[dependency-groups]`
* still produces a valid `TomlKeySegment`), so it cannot tell an incomplete entry from a
* finished one on its own. We hide the "+ Add package" inlay whenever the file has *any*
* `PsiErrorElement` — the click handler eventually shells out to `uv add` / `poetry add`,
* both of which refuse malformed TOML with a raw stderr trace at the user (PY-91037).
*/
@JvmStatic
fun hasParseErrors(file: PsiFile): Boolean =
PsiTreeUtil.findChildOfType(file, PsiErrorElement::class.java) != null
}
private class Collector(editor: Editor) : FactoryInlayHintsCollector(editor) {
override fun collect(element: PsiElement, editor: Editor, sink: InlayHintsSink): Boolean {
if (element !is TomlKeySegment) return true
@@ -6,6 +6,7 @@ import com.intellij.openapi.Disposable
import com.intellij.openapi.project.Project
import com.intellij.ui.DocumentAdapter
import com.intellij.ui.SearchTextField
import com.intellij.ui.components.TextComponentEmptyText
import com.intellij.ui.components.fields.ExtendableTextComponent
import com.intellij.ui.components.fields.ExtendableTextField
import com.jetbrains.python.PyBundle
@@ -36,6 +37,7 @@ internal class PyPackageSearchTextField(private val project: Project) : Extendab
init {
emptyText.text = PyBundle.message("python.toolwindow.packages.search.text.placeholder")
TextComponentEmptyText.setupPlaceholderVisibility(this)
addExtension(object : ExtendableTextComponent.Extension {
override fun getIcon(hovered: Boolean) = AllIcons.Actions.Search
@@ -36,6 +36,7 @@ import com.jetbrains.python.packaging.toolwindow.model.DependencyGroupNode
import com.jetbrains.python.packaging.toolwindow.model.UndeclaredPackagesGroup
import com.jetbrains.python.packaging.toolwindow.packages.tree.renderers.PyPackageTreeCellRenderer
import com.jetbrains.python.packaging.toolwindow.packages.tree.renderers.asInstalledPackageOrNull
import com.jetbrains.python.packaging.toolwindow.packages.tree.renderers.trailingIconTooltip
import com.jetbrains.python.packaging.toolwindow.ui.PyInstallPackageDialog
import com.jetbrains.python.packaging.toolwindow.ui.showChangeVersionPopup
import com.jetbrains.python.packaging.utils.PyPackageCoroutine
@@ -187,7 +188,7 @@ internal class PyPackagesTree(
val row = getClosestRowForLocation(event.x, event.y).takeIf { it >= 0 } ?: return null
val rowBounds = getRowBounds(row) ?: return null
if (event.y < rowBounds.y || event.y >= rowBounds.y + rowBounds.height) return null
val pkg = packageAtRow(row).asInstalledPackageOrNull() ?: return null
val pkg = packageAtRow(row) ?: return null
val node = getPathForRow(row).lastPathComponent as DefaultMutableTreeNode
val renderer = cellRenderer.getTreeCellRendererComponent(
this, node, isPathSelected(getPathForRow(row)), isExpanded(row), model.isLeaf(node), row, hasFocus()
@@ -195,25 +196,25 @@ internal class PyPackagesTree(
renderer.setSize(rowBounds.width, rowBounds.height)
val relativeX = event.x - rowBounds.x
val changeIconX = renderer.inlineChangeVersionIconX
val changeIcon = renderer.inlineChangeVersionIcon
if (changeIconX > 0 && changeIcon != null && relativeX in changeIconX..(changeIconX + changeIcon.iconWidth)) {
val next = pkg.nextVersion?.presentableText
return if (next != null && pkg.canBeUpdated) {
PyBundle.message("python.toolwindow.packages.tooltip.update.to", next)
}
else {
PyBundle.message("python.toolwindow.packages.tooltip.change.version")
val installedPkg = pkg.asInstalledPackageOrNull()
if (installedPkg != null) {
val changeIconX = renderer.inlineChangeVersionIconX
val changeIcon = renderer.inlineChangeVersionIcon
if (changeIconX > 0 && changeIcon != null && relativeX in changeIconX..(changeIconX + changeIcon.iconWidth)) {
val next = installedPkg.nextVersion?.presentableText
return if (next != null && installedPkg.canBeUpdated) {
PyBundle.message("python.toolwindow.packages.tooltip.update.to", next)
}
else {
PyBundle.message("python.toolwindow.packages.tooltip.change.version")
}
}
}
val trailingIconX = renderer.trailingIconX
val trailingIcon = renderer.trailingIcon
if (trailingIconX > 0 && trailingIcon != null && relativeX in trailingIconX..(trailingIconX + trailingIcon.iconWidth)) {
return PyBundle.message("python.toolwindow.packages.tooltip.uninstall")
}
return null
val overTrailingIcon = trailingIconX > 0 && trailingIcon != null && relativeX in trailingIconX..(trailingIconX + trailingIcon.iconWidth)
return if (overTrailingIcon) pkg.trailingIconTooltip() else null
}
private val hoverHandler = PyPackagesTreeHoverHandler(this)
@@ -1,6 +1,7 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.jetbrains.python.packaging.toolwindow.packages.tree.renderers
import com.jetbrains.python.PyBundle
import com.jetbrains.python.packaging.toolwindow.model.DependencyGroupNode
import com.jetbrains.python.packaging.toolwindow.model.DisplayablePackage
import com.jetbrains.python.packaging.toolwindow.model.InstallablePackage
@@ -62,3 +63,15 @@ internal fun DisplayablePackage?.asInstalledPackageOrNull(): InstalledPackage? =
null,
-> null
}
/** Tooltip for the trailing action icon in a tree row, or `null` if the row type has no action. */
internal fun DisplayablePackage.trailingIconTooltip(): String? = when (this) {
is InstallablePackage -> PyBundle.message("action.PyInstallPackageAction.text")
is InstalledPackage -> PyBundle.message("python.toolwindow.packages.tooltip.uninstall")
is RequirementPackage,
is WorkspaceMember,
is LoadingNode,
is DependencyGroupNode,
is UndeclaredPackagesGroup,
-> null
}
@@ -164,8 +164,14 @@ internal class PyInstallDialogVersionPanel(
selectedPackageName = name
packageInfoLabel.text = "$name ($repoName)"
packageInfoLabel.icon = PyPackageIcons.PackageGray
resetVersionState(repoName)
// Order matters: ask the presenter for the new view state (which snapshots documentation
// + version list for the *newly* selected package) BEFORE clearing local state. The old
// sequence — `resetVersionState` first, then `onPackageSelected` — meant the presenter
// was called after we had wiped `selectedPackageName` / `availableVersions`, so on a
// rapid re-select (typing filters the list, selection follows) the documentation pane
// briefly rendered the previous package while the version state was mid-reset.
val state = presenter.onPackageSelected(descriptionToggle.isSelected)
resetVersionState(repoName)
applyViewState(state)
}
@@ -121,6 +121,8 @@ internal class PyInstallPackageDialog(private val project: Project) : BigPopupUI
private var balloonFullSize: Dimension? = null
private var collapsedSize: Dimension? = null
private var isOpeningFileBrowser = false
override fun createList(): JBList<Any> = resultsList.list
override fun createCellRenderer(): ListCellRenderer<Any> = resultsList.renderer
@@ -206,7 +208,8 @@ internal class PyInstallPackageDialog(private val project: Project) : BigPopupUI
.createComponentPopupBuilder(this, mySearchField)
.setProject(project)
.setModalContext(false)
.setCancelOnWindowDeactivation(false)
.setCancelOnWindowDeactivation(true)
.setCancelCallback { !isOpeningFileBrowser }
.setCancelOnClickOutside(true)
.setRequestFocus(true)
.setResizable(true)
@@ -421,8 +424,14 @@ internal class PyInstallPackageDialog(private val project: Project) : BigPopupUI
}
private fun openFileBrowser() {
val file = FileChooser.chooseFile(packageFileDescriptor, project, null)
if (file != null) mySearchField.text = file.path
isOpeningFileBrowser = true
try {
val file = FileChooser.chooseFile(packageFileDescriptor, project, null)
if (file != null) mySearchField.text = file.path
}
finally {
isOpeningFileBrowser = false
}
}
private fun setupSearchListener() {
@@ -0,0 +1,30 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.jetbrains.python.requirements
/**
* HTML string builder with a compile-visible escape boundary — [text] escapes XML entities,
* [raw] appends verbatim. Splitting the two APIs stops the double-escape / missed-escape
* mistake Ilya's PY-89838 review flagged: at every call site the caller has to pick which
* method to invoke, so the escaping story is explicit at every point instead of relying on
* convention.
*
* `toString` is the read side used by the documentation provider — it must return the
* accumulated HTML, not the default `Object` identity string. That single method is what
* PY-91067 exercised: before the override existed, the Quick Doc tooltip printed
* `HtmlBuffer@<hash>` verbatim.
*/
internal class HtmlBuffer {
private val buffer = StringBuilder()
fun text(value: String): HtmlBuffer {
buffer.append(value.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;"))
return this
}
fun raw(value: String): HtmlBuffer {
buffer.append(value)
return this
}
override fun toString(): String = buffer.toString()
}
@@ -284,25 +284,6 @@ internal class RequirementDocumentationTarget(
replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
}
/**
* HTML string builder with a compile-visible escape boundary — [text] escapes XML entities,
* [raw] appends verbatim. Splitting the two APIs stops the double-escape / missed-escape mistake
* Ilya's review flagged: at every call site you have to pick which one to invoke, so the
* escaping story is explicit at every point instead of relying on convention.
*/
private class HtmlBuffer {
private val buffer = StringBuilder()
fun text(value: String): HtmlBuffer {
buffer.append(value.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;"))
return this
}
fun raw(value: String): HtmlBuffer {
buffer.append(value)
return this
}
}
/**
* Routes our two link schemes:
@@ -30,6 +30,7 @@ import com.jetbrains.python.packaging.packageRequirements.TreeParser
import com.jetbrains.python.packaging.packageRequirements.collectAllNames
import com.jetbrains.python.packaging.pip.PipRepositoryManager
import com.intellij.python.pyproject.PY_PROJECT_TOML
import com.intellij.python.pyproject.PY_PROJECT_TOML_DEPENDENCY_GROUPS
import com.intellij.python.pyproject.PyProjectToml
import com.jetbrains.python.poetry.POETRY_LOCK
import com.jetbrains.python.sdk.pySdkAdditionalData
@@ -276,15 +277,26 @@ internal class PoetryPackageManager(project: Project, sdk: Sdk) : PythonPackageM
}
/**
* Returns Poetry dependency group names from `[tool.poetry.group.<name>.dependencies]` sections.
* Always includes "main" as the first entry (representing `[tool.poetry.dependencies]`).
* Returns Poetry dependency group names. Merges every group source Poetry itself recognises
* (Poetry 2.x accepts PEP 621 / PEP 735 alongside the legacy Poetry layout):
* - "main" — implicit `[tool.poetry.dependencies]` / `[project].dependencies` group;
* - "dev" — legacy `[tool.poetry.dev-dependencies]` shortcut (Poetry <2.0), still installable via
* `poetry install --with dev`;
* - `[tool.poetry.group.<name>.dependencies]` — Poetry-native group layout;
* - `[dependency-groups]` keys — PEP 735 layout;
* - `[project.optional-dependencies]` keys — PEP 621 extras, installable with `poetry install --extras`.
*
* Order: "main" first, then legacy dev, Poetry-native groups, PEP 735 groups, PEP 621 extras.
* Duplicates de-duplicated so a group declared in multiple spots surfaces once.
*/
@ApiStatus.Internal
private fun PyProjectToml.getPoetryGroupNames(): List<String> {
val toolTable = toml.getTable("tool") ?: return PyProjectToml.DEFAULT_GROUP_NAMES
val poetryTable = toolTable.getTable("poetry") ?: return PyProjectToml.DEFAULT_GROUP_NAMES
val groupTable = poetryTable.getTable("group") ?: return PyProjectToml.DEFAULT_GROUP_NAMES
return PyProjectToml.DEFAULT_GROUP_NAMES + groupTable.keySet().toList()
val poetryTable = toml.getTable("tool")?.getTable("poetry")
val legacyDev = listOfNotNull("dev".takeIf { poetryTable?.getTable("dev-dependencies") != null })
val poetryGroups = poetryTable?.getTable("group")?.keySet().orEmpty()
val pep735Groups = toml.getTable(PY_PROJECT_TOML_DEPENDENCY_GROUPS)?.keySet().orEmpty()
val pep621Extras = project.dependencies.optional.keys
return (PyProjectToml.DEFAULT_GROUP_NAMES + legacyDev + poetryGroups + pep735Groups + pep621Extras).distinct()
}
private class PoetryWorkspaceSupport(private val project: Project, private val sdk: Sdk) : PythonWorkspaceSupport {
@@ -163,7 +163,7 @@ private class UvLowLevelImpl<P : PathHolder>(
}
override suspend fun listProjectStructureTree(): PyResult<String> {
val out = uvCli.runUv(cwd, venvPath, false, "tree", "--frozen", "--no-dedupe")
val out = uvCli.runUv(cwd, venvPath, false, "tree", "--frozen", "--no-dedupe", "--all-groups")
.getOr { return it }
return PyExecResult.success(out)
@@ -0,0 +1,84 @@
// 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.packaging
import com.jetbrains.python.requirements.HtmlBuffer
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/**
* PY-91067: the Quick Doc tooltip for `requirements.txt` used to render the accumulator's
* `Object.toString()` identity (`HtmlBuffer@57f54152`) because `HtmlBuffer` never overrode
* `toString()`. These tests pin the accumulator's behavior: `toString` must return the
* accumulated HTML, `text` must escape user-supplied strings, `raw` must emit verbatim, and
* the two paths must compose in call order.
*/
internal class HtmlBufferTest {
@Test
fun `toString returns accumulated content, not the Object identity`() {
val buffer = HtmlBuffer().text("hello").raw("<br>").text("world")
val html = buffer.toString()
assertEquals("hello<br>world", html)
// Guard against the regression itself — the identity string looks like `HtmlBuffer@57f54152`.
assertFalse(
html.startsWith("com.jetbrains.python.requirements.HtmlBuffer@")
|| html.startsWith("HtmlBuffer@"),
"toString must not return the default Object identity string; got `$html`",
)
}
@Test
fun `toString on an empty buffer returns an empty string`() {
assertEquals("", HtmlBuffer().toString())
}
@Test
fun `text escapes XML special characters`() {
val html = HtmlBuffer()
.text("<script>alert('x')</script>")
.toString()
assertEquals("&lt;script&gt;alert('x')&lt;/script&gt;", html)
}
@Test
fun `text escapes an ampersand exactly once`() {
val html = HtmlBuffer().text("Rock & Roll").toString()
assertEquals("Rock &amp; Roll", html)
assertFalse(html.contains("&amp;amp;"), "double-escape regression check")
}
@Test
fun `raw preserves markup verbatim`() {
val html = HtmlBuffer().raw("<b>bold</b>").toString()
assertEquals("<b>bold</b>", html)
}
@Test
fun `text and raw compose in call order`() {
val name = "<foo & bar>"
val html = HtmlBuffer()
.raw("<html>")
.text(name)
.raw("</html>")
.toString()
assertEquals("<html>&lt;foo &amp; bar&gt;</html>", html)
assertTrue(html.startsWith("<html>"), "raw prefix survives verbatim")
assertTrue(html.endsWith("</html>"), "raw suffix survives verbatim")
}
@Test
fun `builder methods return the same instance for chaining`() {
val buffer = HtmlBuffer()
assertTrue(buffer.text("a") === buffer, "text must return the receiver for fluent chaining")
assertTrue(buffer.raw("b") === buffer, "raw must return the receiver for fluent chaining")
}
}
@@ -0,0 +1,90 @@
// 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.packaging
import com.intellij.openapi.application.readAction
import com.intellij.psi.PsiFile
import com.intellij.psi.PsiFileFactory
import com.intellij.testFramework.common.timeoutRunBlocking
import com.intellij.testFramework.junit5.TestApplication
import com.intellij.testFramework.junit5.fixture.projectFixture
import com.jetbrains.python.packaging.toolwindow.marker.PyDependencyGroupInlayHintsProvider
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.toml.lang.psi.TomlFileType
/**
* PY-91037: the "+ Add package" inlay must not appear while `pyproject.toml` is syntactically
* broken. `PyDependencyGroupInlayHintsProvider.getCollectorFor` gates the entire file on
* [PyDependencyGroupInlayHintsProvider.hasParseErrors]; these tests pin the gate.
*
* The regression was: TOML parser recovery preserves a valid-looking `TomlKeySegment` for a
* bare `test` line under `[dependency-groups]`. The segment-level resolver happily classifies
* it as a group, so the inlay was drawn — but clicking it eventually shelled out to
* `uv add` / `poetry add`, both of which refused the malformed file with a raw stderr trace.
* Better no affordance than one that corrupts the file.
*/
@TestApplication
internal class PyDependencyGroupInlayParseErrorGateTest {
private val projectFixture = projectFixture()
private val project get() = projectFixture.get()
@Test
fun `well-formed pyproject with dependency groups has no parse errors`() = timeoutRunBlocking {
val file = readAction { tomlFile(
"""
[project]
name = "pkg"
[dependency-groups]
test = ["pytest"]
lint = ["ruff"]
""".trimIndent()
) }
assertFalse(readAction { PyDependencyGroupInlayHintsProvider.hasParseErrors(file) },
"complete PEP 735 groups must not gate the inlay")
}
@Test
fun `incomplete PEP 735 dependency group entry is treated as parse error`() = timeoutRunBlocking {
val file = readAction { tomlFile(
"""
[dependency-groups]
test
""".trimIndent()
) }
assertTrue(readAction { PyDependencyGroupInlayHintsProvider.hasParseErrors(file) },
"bare `test` line without `=` must trip the inlay gate — this is the PY-91037 regression")
}
@Test
fun `unclosed inline array under dependency-groups is a parse error`() = timeoutRunBlocking {
val file = readAction { tomlFile(
"""
[dependency-groups]
test = ["pytest",
""".trimIndent()
) }
assertTrue(readAction { PyDependencyGroupInlayHintsProvider.hasParseErrors(file) },
"half-written array literal must trip the inlay gate")
}
@Test
fun `unclosed table header trips the inlay gate`() = timeoutRunBlocking {
val file = readAction { tomlFile(
"""
[dependency-groups
test = ["pytest"]
""".trimIndent()
) }
assertTrue(readAction { PyDependencyGroupInlayHintsProvider.hasParseErrors(file) },
"missing `]` on the header must trip the inlay gate")
}
private fun tomlFile(text: String): PsiFile =
PsiFileFactory.getInstance(project).createFileFromText("pyproject.toml", TomlFileType, text)
}
@@ -46,7 +46,7 @@ private class FakeRepository(
}
private class FakeRepositoryManager(
override val repositories: List<PyPackageRepository>,
override val allRepositories: List<PyPackageRepository>,
var awaitReadyCalled: Boolean = false,
) : PythonRepositoryManager {
override val project: Project get() = error("Project unused in this test")
@@ -79,7 +79,7 @@ internal class PyInstallDialogSearchAggregatorTest {
@Test
fun `awaitReady is called before searching`() = runTest {
val manager = FakeRepositoryManager(repositories = emptyList())
val manager = FakeRepositoryManager(allRepositories =emptyList())
aggregateInstallDialogSearch(manager, extraRepositories = emptyList(), query = "anything")
@@ -89,7 +89,7 @@ internal class PyInstallDialogSearchAggregatorTest {
@Test
fun `built-in repositories are searched through the manager`() = runTest {
val pypi = FakeRepository("PyPI", "https://pypi.org/simple", packages = listOf("requests", "requests-mock", "flask"))
val manager = FakeRepositoryManager(repositories = listOf(pypi))
val manager = FakeRepositoryManager(allRepositories =listOf(pypi))
val result = aggregateInstallDialogSearch(manager, extraRepositories = emptyList(), query = "request")
@@ -101,7 +101,7 @@ internal class PyInstallDialogSearchAggregatorTest {
fun `enabled extra repository not already covered is searched directly`() = runTest {
val pypi = FakeRepository("PyPI", "https://pypi.org/simple", packages = listOf("flask"))
val internalRepo = FakeRepository("Internal", "https://repo.internal/simple", packages = listOf("internal-tool", "unrelated"))
val manager = FakeRepositoryManager(repositories = listOf(pypi))
val manager = FakeRepositoryManager(allRepositories =listOf(pypi))
val result = aggregateInstallDialogSearch(manager, extraRepositories = listOf(internalRepo), query = "internal")
@@ -113,7 +113,7 @@ internal class PyInstallDialogSearchAggregatorTest {
fun `extra repository with same url as a built-in is not searched twice`() = runTest {
val pypi = FakeRepository("PyPI", "https://pypi.org/simple", packages = listOf("requests"))
val duplicate = FakeRepository("PyPI mirror", "https://pypi.org/simple", packages = listOf("requests"))
val manager = FakeRepositoryManager(repositories = listOf(pypi))
val manager = FakeRepositoryManager(allRepositories =listOf(pypi))
val result = aggregateInstallDialogSearch(manager, extraRepositories = listOf(duplicate), query = "requests")
@@ -124,7 +124,7 @@ internal class PyInstallDialogSearchAggregatorTest {
@Test
fun `disabled extra repository is skipped`() = runTest {
val internalRepo = FakeRepository("Internal", "https://repo.internal/simple", enabled = false, packages = listOf("internal-tool"))
val manager = FakeRepositoryManager(repositories = emptyList())
val manager = FakeRepositoryManager(allRepositories =emptyList())
val result = aggregateInstallDialogSearch(manager, extraRepositories = listOf(internalRepo), query = "internal")
@@ -0,0 +1,148 @@
// 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.packaging.repository
import com.intellij.openapi.project.Project
import com.jetbrains.python.Result
import com.jetbrains.python.errorProcessing.PyResult
import com.jetbrains.python.packaging.PyPackageName
import com.jetbrains.python.packaging.PyPackageVersion
import com.jetbrains.python.packaging.PyRequirement
import com.jetbrains.python.packaging.cache.PythonPackageSearchPage
import com.jetbrains.python.packaging.cache.PythonPackageSearchResult
import com.jetbrains.python.packaging.common.PythonPackageDetails
import com.jetbrains.python.packaging.common.PythonRepositoryPackageSpecification
import com.jetbrains.python.packaging.management.PythonRepositoryManager
import com.jetbrains.python.packaging.repository.PyPackageRepository
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/**
* PY-91041: disabled repositories used to keep contributing packages to the PPTW list and the
* install-dialog search because `searchPackages` / `hasPackageSnapshot` iterated the raw
* repository list without checking [PyPackageRepository.enabled]. The fix promoted the filter
* into the interface — the `repositories` property is a default getter over
* `allRepositories.filter { it.enabled }` — so every query path picks up the exclusion
* automatically.
*
* These tests pin the resulting contract:
* - the default `repositories` getter hides disabled entries;
* - `searchPackages(needle)` returns only enabled repositories;
* - `hasPackageSnapshot(name)` short-circuits over disabled repositories;
* - toggling `enabled` at runtime is picked up without rebuilding the manager.
*/
internal class PythonRepositoryManagerFilteringTest {
@Test
fun `default repositories getter filters out disabled entries`() {
val enabled = fakeRepo("PyPI", packages = listOf("requests"), enabled = true)
val disabled = fakeRepo("Internal", packages = listOf("internal-tool"), enabled = false)
val manager = FilteringFakeRepositoryManager(listOf(enabled, disabled))
assertEquals(listOf<PyPackageRepository>(enabled, disabled), manager.allRepositories, "raw list is the source of truth")
assertEquals(listOf<PyPackageRepository>(enabled), manager.repositories, "filtered view hides disabled entries")
}
@Test
fun `searchPackages skips disabled repositories`() {
val enabled = fakeRepo("PyPI", packages = listOf("requests", "requests-mock"), enabled = true)
val disabled = fakeRepo("Internal", packages = listOf("requests-internal"), enabled = false)
val manager = FilteringFakeRepositoryManager(listOf(enabled, disabled))
val hits = manager.searchPackages(needle = "requests")
assertEquals(setOf<PyPackageRepository>(enabled), hits.keys, "disabled repo must not appear in search results")
assertFalse(disabled in hits.keys, "regression guard for PY-91041")
assertEquals(listOf("requests", "requests-mock"), hits.getValue(enabled).firstPage())
}
@Test
fun `hasPackageSnapshot short-circuits over disabled repositories`() {
val enabled = fakeRepo("PyPI", packages = listOf("flask"), enabled = true)
val disabled = fakeRepo("Internal", packages = listOf("internal-only"), enabled = false)
val manager = FilteringFakeRepositoryManager(listOf(enabled, disabled))
assertTrue(manager.hasPackageSnapshot("flask"), "enabled repo still contributes existence checks")
assertFalse(manager.hasPackageSnapshot("internal-only"), "disabled repo must not answer existence checks")
}
@Test
fun `re-enabling a repository restores its results without rebuilding the manager`() {
val toggle = fakeRepo("Toggle", packages = listOf("secret"), enabled = false)
val manager = FilteringFakeRepositoryManager(listOf(toggle))
assertTrue(manager.repositories.isEmpty(), "disabled at start — nothing to search")
assertFalse(manager.hasPackageSnapshot("secret"))
toggle.enabled = true
assertEquals(listOf<PyPackageRepository>(toggle), manager.repositories, "flip flows through the default filter")
assertTrue(manager.hasPackageSnapshot("secret"), "flip is picked up on the next query")
}
private fun fakeRepo(name: String, packages: List<String>, enabled: Boolean): ToggleableFakeRepository =
ToggleableFakeRepository(name, url = "https://$name.example.test/simple", enabled = enabled, packages = packages)
private fun PythonPackageSearchResult.firstPage(): List<String> {
val page = pages.firstOrNull() ?: return emptyList()
val result = page.contents()
return (result as? Result.Success<List<String>>)?.result ?: emptyList()
}
}
/**
* Test-only [PythonRepositoryManager] that returns its constructor argument verbatim as
* [allRepositories]. Everything else is left to the interface defaults — that is the whole
* point of the test: the default `repositories` / `searchPackages` / `hasPackageSnapshot`
* paths must apply the `enabled` filter without any help from the implementation.
*/
private class FilteringFakeRepositoryManager(
override val allRepositories: List<PyPackageRepository>,
) : PythonRepositoryManager {
override val project: Project get() = error("Project unused in this test")
override suspend fun getPackageDetails(packageName: String, repository: PyPackageRepository?): PyResult<PythonPackageDetails> =
error("not used in this test")
override suspend fun getLatestVersion(packageName: String, repository: PyPackageRepository?): PyPackageVersion? =
error("not used in this test")
override suspend fun getVersions(packageName: String, repository: PyPackageRepository?): List<String>? =
error("not used in this test")
override suspend fun refreshCaches() = error("not used in this test")
override suspend fun initCaches() = error("not used in this test")
override suspend fun findPackageSpecification(requirement: PyRequirement, repository: PyPackageRepository?): PythonRepositoryPackageSpecification? =
error("not used in this test")
}
/**
* Minimal test repository — an in-memory package set searched by prefix normalisation, plus a
* flip-able `enabled` flag to exercise runtime toggles without going through the persistent
* `PyPackageRepositories` service. Distinct name from the aggregator test's `FakeRepository`
* to avoid a same-package top-level clash.
*/
private class ToggleableFakeRepository(
name: String,
url: String,
enabled: Boolean,
private val packages: List<String>,
) : PyPackageRepository(name, url, null) {
init {
this.enabled = enabled
}
override fun search(needle: String, pageSize: Int): PythonPackageSearchResult {
val normalized = PyPackageName.normalizePackageName(needle)
val matches = packages.filter { it.contains(normalized, ignoreCase = true) }
return PythonPackageSearchResult(
total = matches.size,
pages = listOf(object : PythonPackageSearchPage {
override fun contents(): Result<List<String>, PythonPackageSearchPage.DataInvalidatedError> =
Result.Success(matches)
}),
maxPageSize = pageSize,
)
}
override fun hasPackage(name: String): Boolean = packages.contains(name)
}
@@ -45,7 +45,7 @@ internal class TestPythonRepositoryManager(
return this
}
override val repositories: List<PyPackageRepository>
override val allRepositories: List<PyPackageRepository>
get() = listOf(TestPackageRepository(packageNames))
override suspend fun getPackageDetails(packageName: String, repository: PyPackageRepository?): PyResult<PythonPackageDetails> {