tmalanin/PTW-fixes-262

PY-90072

PPTW: promote anchor-toggle action to the tool-window header

Add PyTogglePackagingToolWindowAnchorAction so users can switch
between bottom and right docking directly from the PPTW header
without opening the gear menu. Also center the "Advanced Package
Install" icon between the search field and the right border.

PPTW: show active interpreter path in the tool-window title

Render the SDK interpreter path next to "Python Packages" in the
tool-window stripe title using PyInterpreterHeaderTitleRenderer.
The path start-ellipsizes on resize so the tail (.venv/bin/python)
stays visible when the header is narrow. Clears automatically when
switching to a file with no Python SDK.ё

Original MR: IJ-MR-215692

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

Original MR: IJ-MR-214946

GitOrigin-RevId: 552de7eb4445ee1f185de0b03ebf9db71c75faa4
This commit is contained in:
TimurMalanin
2026-07-29 15:56:12 +00:00
committed by intellij-monorepo-bot
parent d27b3ba5d2
commit fe1905f1e2
27 changed files with 667 additions and 122 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"/>
@@ -1625,7 +1625,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
@@ -1640,6 +1640,8 @@ package.install.with.options.dialog.message=Options:
package.install.with.options.dialog.title=Package Install with Options
python.toolwindow.packages.collapse.all.action=Collapse All
python.toolwindow.packages.reload.packages.action=Reload Packages
python.toolwindow.packages.move.to.right.action=Move to Right
python.toolwindow.packages.move.to.bottom.action=Move to Bottom
python.toolwindow.packages.interpreters.action=Interpreters...
python.toolwindow.packages.repositories.action=Repositories...
python.toolwindow.packages.settings.group=Settings
@@ -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()
@@ -0,0 +1,82 @@
// 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.openapi.actionSystem.ActionToolbar
import com.intellij.openapi.ui.shortenTextWithEllipsis
import com.intellij.openapi.util.NlsSafe
import com.intellij.openapi.util.text.HtmlChunk
import com.intellij.openapi.wm.ToolWindow
import com.intellij.ui.ColorUtil
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.util.ui.GraphicsUtil
import com.intellij.util.ui.UIUtil
import org.jetbrains.annotations.Nls
import java.awt.Font
/**
* Renders the "Python Packages" tool-window title with the active interpreter path appended in
* a lighter (context-help) foreground. The path start-ellipsizes when the header is too narrow
* to fit it in full — the tail (`.venv/bin/python`) is what distinguishes environments, so the
* leading directories are the ones that give way.
*
* The renderer piggybacks on the fact that the platform id-label is a plain `JLabel`; wrapping
* the pieces in HTML with inline styles keeps everything inside the label the platform already
* paints, so we avoid custom components on the header.
*
* The renderer is EDT-affine — call [update] whenever the active SDK changes and [refit] on
* tool-window resize (or let a caller invoke [refit] directly from a component listener). If
* the tool window is not resolvable (test environments, disposed project), calls become no-ops.
*/
internal class PyInterpreterHeaderTitleRenderer(
private val toolWindow: ToolWindow,
@Nls private val plainTitle: String,
) {
private var currentPath: String? = null
@RequiresEdt
fun update(path: String?) {
currentPath = path?.takeIf { it.isNotEmpty() }
refit()
}
@RequiresEdt
fun refit() {
val path = currentPath
if (path == null) {
toolWindow.stripeTitle = plainTitle
return
}
toolWindow.stripeTitle = renderHtml(fitPathToHeader(path))
}
private fun fitPathToHeader(path: String): @NlsSafe String {
val width = toolWindow.component.width.takeIf { it > 0 } ?: return path
val font = UIUtil.getLabelFont()
val budget = (width
- GraphicsUtil.stringWidth(plainTitle, font.deriveFont(Font.BOLD))
- GraphicsUtil.stringWidth(" ", font)
- RIGHT_TOOLBAR_RESERVED_PX
).coerceAtLeast(0)
return shortenTextWithEllipsis(
text = path,
minTextPrefixLength = 0,
minTextSuffixLength = 1,
maxTextPrefixRatio = 0f,
maxTextWidth = budget,
getTextWidth = { GraphicsUtil.stringWidth(it, font) },
useEllipsisSymbol = true,
)
}
private fun renderHtml(fittedPath: @NlsSafe String): @Nls String {
val greyHex = ColorUtil.toHtmlColor(UIUtil.getContextHelpForeground())
val title = HtmlChunk.text(plainTitle).bold()
val path = HtmlChunk.text(fittedPath).wrapWith(HtmlChunk.span("font-weight:normal;color:$greyHex"))
return HtmlChunk.html().children(title, HtmlChunk.nbsp(2), path).toString()
}
companion object {
/** Width of the right-side header toolbar (3 action buttons and spaces). */
private val RIGHT_TOOLBAR_RESERVED_PX: Int get() = 5 * ActionToolbar.DEFAULT_MINIMUM_BUTTON_SIZE.width
}
}
@@ -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,11 +22,15 @@ 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
import com.jetbrains.python.packaging.toolwindow.actions.PyTogglePackagingToolWindowAnchorAction
import com.jetbrains.python.packaging.toolwindow.details.PyPackageInfoPanel
import com.jetbrains.python.packaging.toolwindow.model.DisplayablePackage
import com.jetbrains.python.packaging.toolwindow.model.PyPackagesViewData
@@ -37,7 +45,10 @@ 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 java.awt.event.ComponentAdapter
import java.awt.event.ComponentEvent
import javax.swing.JComponent
import javax.swing.JPanel
import javax.swing.SwingUtilities
@@ -72,6 +83,7 @@ internal class PyPackagingToolWindowPanel(private val project: Project) : Simple
private val listWithSearchPanel by lazy { buildListWithSearch() }
private val contentPanel: JPanel
private var headerTitleRenderer: PyInterpreterHeaderTitleRenderer? = null
internal var contentVisible: Boolean
get() = contentPanel.isVisible
@@ -112,13 +124,24 @@ internal class PyPackagingToolWindowPanel(private val project: Project) : Simple
fun getSelectedPackage(): DisplayablePackage? = packageListController.getSelectedPackages().firstOrNull()
private fun setupToolWindowTitleActions() {
val toolWindow = ToolWindowManager.getInstance(project).getToolWindow("Python Packages") ?: return
val toolWindow = ToolWindowManager.getInstance(project).getToolWindow(PY_PACKAGES_TOOL_WINDOW_ID) ?: return
// Show the default "Python Packages" id-label in the header instead of the SDK dropdown.
// SDK can still be picked from elsewhere; the id-label is more discoverable as the title.
toolWindow.component.putClientProperty(ToolWindowContentUi.DONT_HIDE_TOOLBAR_IN_HEADER, true)
val gearActions = ActionManager.getInstance().getAction(ADDITIONAL_PACKAGE_TOOLBAR_ACTION_ID) as ActionGroup
toolWindow.setAdditionalGearActions(gearActions)
toolWindow.setTitleActions(listOf(PyTogglePackagingToolWindowAnchorAction()))
headerTitleRenderer = PyInterpreterHeaderTitleRenderer(
toolWindow = toolWindow,
plainTitle = message("toolwindow.stripe.Python_Packages_Tool"),
)
addComponentListener(object : ComponentAdapter() {
override fun componentResized(e: ComponentEvent) {
SwingUtilities.invokeLater { headerTitleRenderer?.refit() }
}
})
}
private fun createContentPanel(): JComponent {
@@ -159,7 +182,7 @@ internal class PyPackagingToolWindowPanel(private val project: Project) : Simple
}
private fun isToolWindowHorizontal(): Boolean {
val toolWindow = ToolWindowManager.getInstance(project).getToolWindow("Python Packages")
val toolWindow = ToolWindowManager.getInstance(project).getToolWindow(PY_PACKAGES_TOOL_WINDOW_ID)
?: return false
val anchor = toolWindow.anchor
return anchor == ToolWindowAnchor.BOTTOM || anchor == ToolWindowAnchor.TOP
@@ -174,21 +197,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(PY_INSTALL_PACKAGE_ACTION_ID)
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(left = hPad))
}.customize(UnscaledGapsY.EMPTY)
}.apply {
border = JBUI.Borders.empty(vPad, hPad, vPad, hPad)
}
toolbar.targetComponent = searchBarPanel
return searchBarPanel
}
private fun trackModules() {
@@ -232,6 +273,11 @@ internal class PyPackagingToolWindowPanel(private val project: Project) : Simple
packageListController.startSdkInit()
}
@RequiresEdt
fun setInterpreterPath(path: String?) {
headerTitleRenderer?.update(path)
}
internal fun setRefreshIndicatorVisible(visible: Boolean) {
packageListController.setLoadingState(visible)
}
@@ -272,8 +318,12 @@ internal class PyPackagingToolWindowPanel(private val project: Project) : Simple
override fun dispose() {}
companion object {
internal const val PY_PACKAGES_TOOL_WINDOW_ID: String = "Python Packages"
@Language("devkit-action-id")
private const val ADDITIONAL_PACKAGE_TOOLBAR_ACTION_ID = "PyPackageToolbarAdditional"
@Language("devkit-action-id")
private const val PY_INSTALL_PACKAGE_ACTION_ID = "PyInstallPackageAction"
}
}
@@ -38,6 +38,7 @@ import com.jetbrains.python.packaging.common.PythonOutdatedPackage
import com.jetbrains.python.packaging.common.PythonPackage
import com.jetbrains.python.packaging.common.PythonPackageDetails
import com.jetbrains.python.packaging.common.PythonPackageManagementListener
import com.jetbrains.python.sdk.pyInterpreterPresentation
import com.jetbrains.python.packaging.common.PythonRepositoryPackageSpecification
import com.jetbrains.python.packaging.conda.CondaPackage
import com.jetbrains.python.packaging.conda.CondaPackageRepository
@@ -376,6 +377,7 @@ internal class PyPackagingToolWindowService(val project: Project, val serviceSco
toolWindowPanel?.let {
it.packageListController.setLoadingState(false)
it.contentVisible = false
it.setInterpreterPath(null)
}
}
showNoInterpreterMessage()
@@ -385,6 +387,7 @@ internal class PyPackagingToolWindowService(val project: Project, val serviceSco
withContext(Dispatchers.EDT) {
toolWindowPanel?.let {
it.startLoadingSdk(sdk.name)
it.setInterpreterPath(sdk.pyInterpreterPresentation().fullName)
it.syncSdkControllerSelection(sdk)
}
}
@@ -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 {
@@ -0,0 +1,45 @@
// 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.actions
import com.intellij.icons.AllIcons
import com.intellij.openapi.actionSystem.ActionUpdateThread
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.project.DumbAwareAction
import com.intellij.openapi.wm.ToolWindow
import com.intellij.openapi.wm.ToolWindowAnchor
import com.intellij.openapi.wm.ToolWindowManager
import com.jetbrains.python.PyBundle
import com.jetbrains.python.packaging.toolwindow.PyPackagingToolWindowPanel
internal class PyTogglePackagingToolWindowAnchorAction : DumbAwareAction() {
override fun update(e: AnActionEvent) {
val toolWindow = findToolWindow(e)
if (toolWindow == null) {
e.presentation.isEnabledAndVisible = false
return
}
e.presentation.isEnabledAndVisible = true
val (icon, text) = if (toolWindow.anchor == ToolWindowAnchor.RIGHT) {
AllIcons.Actions.MoveToBottomRight to PyBundle.message("python.toolwindow.packages.move.to.bottom.action")
}
else {
AllIcons.Actions.MoveToRightBottom to PyBundle.message("python.toolwindow.packages.move.to.right.action")
}
e.presentation.icon = icon
e.presentation.text = text
}
override fun actionPerformed(e: AnActionEvent) {
val toolWindow = findToolWindow(e) ?: return
val target = if (toolWindow.anchor == ToolWindowAnchor.RIGHT) ToolWindowAnchor.BOTTOM else ToolWindowAnchor.RIGHT
toolWindow.setAnchor(target, null)
}
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT
private fun findToolWindow(e: AnActionEvent): ToolWindow? {
val project = e.project ?: return null
return ToolWindowManager.getInstance(project).getToolWindow(PyPackagingToolWindowPanel.PY_PACKAGES_TOOL_WINDOW_ID)
}
}
@@ -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> {