mirror of
https://gitflic.ru/project/openide/openide.git
synced 2026-09-27 10:03:11 +07:00
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:
committed by
intellij-monorepo-bot
parent
2dc2b46a66
commit
187a8a8341
@@ -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()
|
||||
|
||||
-43
@@ -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)
|
||||
}
|
||||
}
|
||||
+39
-13
@@ -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 {
|
||||
|
||||
+17
@@ -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
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+16
-15
@@ -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)
|
||||
|
||||
+13
@@ -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
|
||||
}
|
||||
|
||||
+7
-1
@@ -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("&", "&").replace("<", "<").replace(">", ">"))
|
||||
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("&", "&").replace("<", "<").replace(">", ">")
|
||||
}
|
||||
|
||||
/**
|
||||
* 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("&", "&").replace("<", "<").replace(">", ">"))
|
||||
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("<script>alert('x')</script>", html)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `text escapes an ampersand exactly once`() {
|
||||
val html = HtmlBuffer().text("Rock & Roll").toString()
|
||||
|
||||
assertEquals("Rock & Roll", html)
|
||||
assertFalse(html.contains("&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><foo & bar></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")
|
||||
}
|
||||
}
|
||||
+90
@@ -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)
|
||||
}
|
||||
+6
-6
@@ -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")
|
||||
|
||||
|
||||
+148
@@ -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)
|
||||
}
|
||||
+1
-1
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user