PY-91529 clicking the PPTW install spinner opens Python Process Output

The spinner that replaces the "+" icon while a package installs inherited that
icon's hit-box, so clicking it still popped up the install dialog: the trailing
icon hit-test in PyPackagesTree.handleLinkClick runs before the linkStartX guard
the original fix relied on, and treated every trailing icon as an action.

The renderer now publishes what its trailing icon means (TrailingIconKind ACTION
or PROGRESS), so click handling and tooltips have to spell out both cases in an
exhaustive when instead of assuming an action. A click on PROGRESS opens Python
Process Output with the running install command preselected, and the tooltip
offers that rather than "Install package".

To make the command findable, the tree owns the TraceContext its install runs in
(PyPackagingToolWindowService.installPackage accepts it instead of opening a
nested one, so the uuid the tree holds is the one the spawned pip / uv process
reports) and stores that uuid next to the install key in PyActiveInstalls.
PyInstallPackageDialog stores the uuid of the trace it already owned, so the
tree's spinner also works for an install started from the dialog.

Out of scope:
* an install started from the package details pane
  (PyPackageDescriptionController.wrapInvokeOp) carries no trace, because the
  trace is born inside the service below its suspend () -> Unit callback. Its
  spinner keeps a plain "Installing package X..." tooltip and a no-op click;
  threading a trace through that callback is a separate refactoring.
* updatePackageToLatest gets no trace: renderInstalledPackage never consults
  isInstalling, so an installed row shows the uninstall icon or nothing, never
  a spinner.


(cherry picked from commit 7c18eca608a99067fecfd48d0a41359f408e0097)

IJ-MR-217932

GitOrigin-RevId: 9755d3bc2c6c19465ac4d9b08ee22205127cfe13
This commit is contained in:
Ivan Kulikov
2026-08-11 18:37:26 +00:00
committed by intellij-monorepo-bot
parent f55f5c8e0e
commit 77bbf5696d
8 changed files with 263 additions and 52 deletions
@@ -1695,6 +1695,7 @@ python.toolwindow.packages.installed.from=Installed from: {0}
python.toolwindow.packages.tooltip.change.version=Change version
python.toolwindow.packages.tooltip.update.to=Update to {0}
python.toolwindow.packages.tooltip.uninstall=Uninstall package
python.toolwindow.packages.tooltip.show.install.output=Show installation output
INLAY.py.packaging.group.name=Add package to dependency group
INLAY.py.packaging.group.description=Adds a clickable "+ Add package" inlay hint after dependency-group headers in pyproject.toml
INLAY.py.packaging.group.add=+ Add package
@@ -9,7 +9,7 @@ import com.jetbrains.python.packaging.PyPackageName
import java.util.concurrent.ConcurrentHashMap
/**
* Per-SDK set of "installs currently running", keyed by opaque strings — the normalized package
* Per-SDK map of "installs currently running", keyed by opaque strings — the normalized package
* name for package installs (see [packageKey]) or the install dialog's namespaced `"location:"` /
* `"command:"` keys. It is the single source of truth behind the tool-window tree link, the info
* pane and the install dialog, so a package cannot be installed twice at once into the same
@@ -23,24 +23,38 @@ import java.util.concurrent.ConcurrentHashMap
* Thread-safe.
*/
internal class PyActiveInstalls {
private val keys: MutableSet<String> = ConcurrentHashMap.newKeySet()
private val installs: ConcurrentHashMap<String, ActiveInstall> = ConcurrentHashMap()
/** State of one running install. A value type because [ConcurrentHashMap] forbids null values. */
private class ActiveInstall(val traceUuid: String?)
/** `true` while an install keyed by exactly [key] is running. */
fun isInstalling(key: String): Boolean = keys.contains(key)
fun isInstalling(key: String): Boolean = installs.containsKey(key)
/** `true` while a package named [packageName] (any version) is being installed. */
fun isPackageInstalling(packageName: String): Boolean = isInstalling(packageKey(packageName))
/** Records [key]. Returns `false` if it was already recorded (so a repeated trigger is rejected). */
fun mark(key: String): Boolean = keys.add(key)
/**
* Records [key], optionally tagging it with the uuid of the [com.jetbrains.python.TraceContext] the
* install runs in so its command can be located later (see [traceUuid]). Returns `false` if [key]
* was already recorded (so a repeated trigger is rejected); the uuid of the install that is
* actually running is kept in that case, never overwritten by the rejected caller.
*/
fun mark(key: String, traceUuid: String? = null): Boolean = installs.putIfAbsent(key, ActiveInstall(traceUuid)) == null
/** Clears [key]. Returns `false` if it was not recorded. */
fun unmark(key: String): Boolean = keys.remove(key)
/** Clears [key] along with its trace uuid. Returns `false` if it was not recorded. */
fun unmark(key: String): Boolean = installs.remove(key) != null
/**
* Uuid of the trace the install under [key] runs in, or `null` when nothing is running under [key]
* or the install was started without a trace — not every surface owns one.
*/
fun traceUuid(key: String): String? = installs[key]?.traceUuid
companion object {
private val KEY = Key.create<PyActiveInstalls>(PyActiveInstalls::class.java.name)
/** The [Sdk]'s active-installations set, created on first use and living in its user data. */
/** The [Sdk]'s active-installations map, created on first use and living in its user data. */
fun forSdk(sdk: Sdk): PyActiveInstalls = of(sdk)
/** Storage accessor by raw [UserDataHolder] — the SDK in production, any holder in tests. */
@@ -116,15 +116,24 @@ internal class PyPackagingToolWindowService(val project: Project, val serviceSco
/** `true` while a package named [packageName] (any version) is being installed on [sdk]. */
fun isPackageInstalling(sdk: Sdk, packageName: String): Boolean = PyActiveInstalls.forSdk(sdk).isPackageInstalling(packageName)
/** Records [key] as an active install on [sdk]. Returns `false` if already recorded (rejects a rapid re-trigger). */
fun markInstalling(sdk: Sdk, key: String): Boolean =
PyActiveInstalls.forSdk(sdk).mark(key).also { if (it) fireInstallStateChanged() }
/**
* Records [key] as an active install on [sdk]. Returns `false` if already recorded (rejects a rapid re-trigger).
*
* [traceUuid] — uuid of the trace the install runs in, for callers that own one (see [installPackage]'s
* `trace` parameter). Stored so a surface showing the install as in progress can point the user at the
* running command's output; cleared again by [unmarkInstalling].
*/
fun markInstalling(sdk: Sdk, key: String, traceUuid: String? = null): Boolean =
PyActiveInstalls.forSdk(sdk).mark(key, traceUuid).also { if (it) fireInstallStateChanged() }
/** Clears [key] on [sdk]; must run in a `finally` / completion handler so a cancelled install can't leak it. */
fun unmarkInstalling(sdk: Sdk, key: String) {
if (PyActiveInstalls.forSdk(sdk).unmark(key)) fireInstallStateChanged()
}
/** Uuid of the trace of the install running under [key] on [sdk], or `null` if unknown or nothing is running. */
fun installTraceUuid(sdk: Sdk, key: String): String? = PyActiveInstalls.forSdk(sdk).traceUuid(key)
/**
* Subscribes [listener] to any change of the active-installations state; it is invoked on the EDT
* and unregistered when [parent] is disposed. The signal is not SDK-filtered (a listener repaints
@@ -354,17 +363,24 @@ internal class PyPackagingToolWindowService(val project: Project, val serviceSco
}
}
/**
* [trace] — when non-null the install runs directly in this trace instead of a fresh nested one, so
* the caller's uuid is the one the spawned pip / uv process reports. Callers that show their own
* in-progress UI need that: nothing between here and the process launcher adds another trace, so
* whichever trace wraps this call is the one the process is tagged with.
*/
suspend fun installPackage(
installRequest: PythonPackageInstallRequest,
options: List<String> = emptyList(),
workspaceMember: PyWorkspaceMember? = null,
dependencyGroup: PyDependencyGroup? = null,
trace: TraceContext? = null,
) {
val context = sdkContext ?: return
val managerUI = context.managerUI
val module = workspaceMember?.let { context.manager.workspaceSupport?.resolveModule(it) }
withContext(TraceContext(message("trace.context.packaging.tool.window.install"))) {
withContext(trace ?: TraceContext(message("trace.context.packaging.tool.window.install"))) {
PythonPackagesToolwindowStatisticsCollector.installPackageEvent.log(project)
managerUI.installPackagesRequestBackground(installRequest, options, module, dependencyGroup)?.let {
handleActionCompleted(
@@ -36,6 +36,7 @@ import com.jetbrains.python.packaging.toolwindow.model.WorkspaceMember
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.TrailingIconKind
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.statistics.PyInstallDialogSource
@@ -229,8 +230,14 @@ internal class PyPackagesTree(
val trailingIconX = renderer.trailingIconX
val trailingIcon = renderer.trailingIcon
val trailingIconKind = renderer.trailingIconKind
val overTrailingIcon = trailingIconX > 0 && trailingIcon != null && relativeX in trailingIconX..(trailingIconX + trailingIcon.iconWidth)
return if (overTrailingIcon) pkg.trailingIconTooltip() else null
if (!overTrailingIcon) return null
return when (trailingIconKind) {
TrailingIconKind.PROGRESS -> installSpinnerTooltip(pkg)
TrailingIconKind.ACTION -> pkg.trailingIconTooltip()
null -> null
}
}
private val hoverHandler = PyPackagesTreeHoverHandler(this)
@@ -360,15 +367,22 @@ internal class PyPackagesTree(
val trailingIconX = renderer.trailingIconX
val trailingIcon = renderer.trailingIcon
val trailingIconKind = renderer.trailingIconKind
if (trailingIconX > 0 && trailingIcon != null && relativeX in trailingIconX..(trailingIconX + trailingIcon.iconWidth)) {
val handled = when (pkg) {
is InstalledPackage -> { setSelectionRow(row); handlePackageSelection(pkg); deletePackageInline(pkg); true }
is InstallablePackage -> { setSelectionRow(row); handlePackageSelection(pkg); showInstallDialog(pkg); true }
is RequirementPackage,
is UndeclaredPackagesGroup,
is DependencyGroupNode,
is WorkspaceMember,
is LoadingNode -> false
val handled = when (trailingIconKind) {
// A spinner is a progress indicator, not the action button whose hit-box it inherited: show the
// output of the install it stands for instead of re-opening the install dialog (PY-91529).
TrailingIconKind.PROGRESS -> { setSelectionRow(row); handlePackageSelection(pkg); showInstallOutput(pkg); true }
TrailingIconKind.ACTION -> when (pkg) {
is InstalledPackage -> { setSelectionRow(row); handlePackageSelection(pkg); deletePackageInline(pkg); true }
is InstallablePackage -> { setSelectionRow(row); handlePackageSelection(pkg); showInstallDialog(pkg); true }
is RequirementPackage,
is UndeclaredPackagesGroup,
is DependencyGroupNode,
is WorkspaceMember,
is LoadingNode -> false
}
null -> false
}
if (handled) return true
}
@@ -397,6 +411,36 @@ internal class PyPackagesTree(
PyInstallPackageDialog(project).show(initialSearchText = pkg.name)
}
/**
* Uuid of the trace the install running for [pkg] on the current SDK was started in, or `null` when
* nothing is installing or the install carries no trace (see [installSpinnerTooltip]).
*/
private fun installTraceUuid(pkg: DisplayablePackage): String? {
val sdk = packagingService.currentSdk ?: return null
return packagingService.installTraceUuid(sdk, PyPackagingToolWindowService.packageKey(pkg.name))
}
/**
* Opens Python Process Output with the command installing [pkg] preselected. Fire-and-forget: the
* query waits for the process to show up — clicking the spinner right after the link can beat the
* actual pip / uv launch — and gives up on its own timeout, so there is nothing to await here.
*/
private fun showInstallOutput(pkg: DisplayablePackage) {
val traceUuid = installTraceUuid(pkg) ?: return
PyPackageCoroutine.launch(project, Dispatchers.Default) {
sendProcessOutputQuery(ProcessOutputQuery.OpenToolWindowByTraceUuid(traceUuid))
}
}
/**
* Tooltip of the running-install spinner: offers the output when the install can be traced back to a
* command, otherwise just states what is going on — an install started from the package details pane
* has no trace to point at.
*/
private fun installSpinnerTooltip(pkg: DisplayablePackage): String =
if (installTraceUuid(pkg) != null) PyBundle.message("python.toolwindow.packages.tooltip.show.install.output")
else PyBundle.message("python.packaging.installing.package", pkg.name)
private fun updatePackageToLatest(pkg: InstalledPackage) {
val versionString = pkg.nextVersion?.presentableText ?: return
val requirement = pyRequirement(pkg.name, pyRequirementVersionSpec(versionString))
@@ -422,10 +466,13 @@ internal class PyPackagesTree(
// Reject a repeated click while this package is already installing on this SDK — otherwise every
// click fires another heavy install coroutine (PY-91529). The renderer greys the link in parallel.
val key = PyPackagingToolWindowService.packageKey(pkg.name)
if (!packagingService.markInstalling(sdk, key)) return
// Own the trace rather than letting the service open a nested one: the uuid stored next to the key
// has to be the one the spawned pip / uv process reports, or clicking the spinner finds nothing.
val trace = TraceContext(PyBundle.message("python.packaging.installing.package", pkg.name), null)
if (!packagingService.markInstalling(sdk, key, trace.uuid.toString())) return
PyPackageCoroutine.launch(project, Dispatchers.IO) {
try {
packagingService.installPackage(installRequest)
packagingService.installPackage(installRequest, trace = trace)
}
finally {
packagingService.unmarkInstalling(sdk, key)
@@ -42,12 +42,16 @@ internal class PyPackageTreeCellRenderer(
private set
private var currentRow: Int = -1
/** Trailing action icon (delete or install) painted inline after the row content. */
/** Trailing icon painted inline after the row content: a row action, or a running-install spinner. */
var trailingIcon: Icon? = null
private set
var trailingIconX: Int = -1
private set
/** What [trailingIcon] means for click handling and tooltips; `null` whenever [trailingIcon] is `null`. */
var trailingIconKind: TrailingIconKind? = null
private set
/** Inline change-version icon painted right after the version text. */
var inlineChangeVersionIcon: Icon? = null
private set
@@ -74,6 +78,7 @@ internal class PyPackageTreeCellRenderer(
currentRow = row
trailingIcon = null
trailingIconX = -1
trailingIconKind = null
inlineChangeVersionIcon = null
inlineChangeVersionIconX = -1
toolTipText = null
@@ -129,6 +134,16 @@ internal class PyPackageTreeCellRenderer(
}
}
/**
* Fills the trailing icon slot, keeping the icon and its [kind] in sync by construction — a `null`
* [icon] reserves the geometry without painting anything and leaves [trailingIconKind] `null`.
*/
private fun setTrailingIcon(x: Int, icon: Icon?, kind: TrailingIconKind) {
trailingIconX = x
trailingIcon = icon
trailingIconKind = icon?.let { kind }
}
private fun isAncestorOnlyMatch(pkg: DisplayablePackage): Boolean {
val query = packagesTree.project.let {
com.jetbrains.python.packaging.toolwindow.PyPackagingToolWindowService.getInstance(it).activeSearchQuery
@@ -225,8 +240,7 @@ internal class PyPackageTreeCellRenderer(
rowIndent - cellChromeWidth() - DELETE_ICON.iconWidth - JBUI.scale(DefaultTreeUI.HORIZONTAL_SELECTION_OFFSET)
val padTo = maxOf(viewportPadTo, minPadTo)
appendTextPadding(padTo)
trailingIconX = textLeftOffset() + padTo
trailingIcon = if (showActions) DELETE_ICON else null
setTrailingIcon(textLeftOffset() + padTo, if (showActions) DELETE_ICON else null, TrailingIconKind.ACTION)
appendTextPadding(padTo + DELETE_ICON.iconWidth)
}
@@ -271,32 +285,30 @@ internal class PyPackageTreeCellRenderer(
appendTextPadding(padTo)
val leftOff = textLeftOffset()
if (packagesTree.isInstalling(pkg.name)) {
// Install in progress (started here, from the info pane, or from the dialog): show a greyed,
// non-clickable "Install" label with a spinner in place of the + icon, regardless of hover.
// linkStartX/linkEndX stay -1 so PyPackagesTree.handleLinkClick rejects the click (PY-91529).
val disabledAttributes = SimpleTextAttributes(SimpleTextAttributes.STYLE_PLAIN, UIUtil.getInactiveTextColor())
append(linkText, disabledAttributes)
appendTextPadding(padTo + textWidth + gap)
trailingIconX = leftOff + padTo + textWidth + gap
trailingIcon = AnimatedIcon.Default.INSTANCE
appendTextPadding(padTo + blockWidth)
}
else if (showActions) {
val linkStyle = if (packagesTree.linkHoveredRow == currentRow) SimpleTextAttributes.STYLE_UNDERLINE else SimpleTextAttributes.STYLE_PLAIN
val linkAttributes = SimpleTextAttributes(linkStyle, LINK_COLOR)
linkStartX = padTo
append(linkText, linkAttributes)
linkEndX = linkStartX + textWidth
appendTextPadding(padTo + textWidth + gap)
trailingIconX = leftOff + padTo + textWidth + gap
trailingIcon = INSTALL_ICON
appendTextPadding(padTo + blockWidth)
}
else {
trailingIconX = leftOff + padTo + textWidth + gap
appendTextPadding(padTo + blockWidth)
val iconX = leftOff + padTo + textWidth + gap
when (chooseInstallableTrailingIconKind(isInstalling = packagesTree.isInstalling(pkg.name), showActions = showActions)) {
TrailingIconKind.PROGRESS -> {
// Install in progress (started here, from the info pane, or from the dialog): show a greyed,
// non-clickable "Install" label with a spinner in place of the + icon, regardless of hover.
// linkStartX/linkEndX stay -1 so the text half is inert; the spinner itself opens the
// installation output instead of the install dialog (PY-91529).
val disabledAttributes = SimpleTextAttributes(SimpleTextAttributes.STYLE_PLAIN, UIUtil.getInactiveTextColor())
append(linkText, disabledAttributes)
appendTextPadding(padTo + textWidth + gap)
setTrailingIcon(iconX, AnimatedIcon.Default.INSTANCE, TrailingIconKind.PROGRESS)
}
TrailingIconKind.ACTION -> {
val linkStyle = if (packagesTree.linkHoveredRow == currentRow) SimpleTextAttributes.STYLE_UNDERLINE else SimpleTextAttributes.STYLE_PLAIN
val linkAttributes = SimpleTextAttributes(linkStyle, LINK_COLOR)
linkStartX = padTo
append(linkText, linkAttributes)
linkEndX = linkStartX + textWidth
appendTextPadding(padTo + textWidth + gap)
setTrailingIcon(iconX, INSTALL_ICON, TrailingIconKind.ACTION)
}
null -> setTrailingIcon(iconX, null, TrailingIconKind.ACTION)
}
appendTextPadding(padTo + blockWidth)
}
private fun rightAlignPadding(depth: Int, blockWidth: Int, iconWidth: Int): Int {
@@ -369,3 +381,28 @@ internal fun chooseInlineChangeVersionIcon(
else -> null
}
/**
* What the trailing icon of a row means, so that everything hit-testing it (click handling,
* tooltips) has to spell out both cases instead of assuming every trailing icon is actionable.
*/
internal enum class TrailingIconKind {
/** A row action the user invokes: install, uninstall. */
ACTION,
/** A running-install spinner; clicking it shows that install's output rather than acting on the row. */
PROGRESS,
}
/**
* Chooses what the trailing icon of an installable row stands for, `null` for "no icon".
*
* Extracted from the renderer so the precedence can be unit-tested without a Swing tree: a running
* install outranks the hover state, so the row shows a spinner instead of an install icon even while
* hovered (PY-91529).
*/
internal fun chooseInstallableTrailingIconKind(isInstalling: Boolean, showActions: Boolean): TrailingIconKind? = when {
isInstalling -> TrailingIconKind.PROGRESS
showActions -> TrailingIconKind.ACTION
else -> null
}
@@ -673,7 +673,9 @@ internal class PyInstallPackageDialog(private val project: Project) : BigPopupUI
val sdk = packagingService.currentSdk ?: return
val trace = com.jetbrains.python.TraceContext(title, null)
val installKey = installKeyOf(target)
packagingService.markInstalling(sdk, installKey)
// [trace] is the one the spawned process reports (this path calls the manager UI directly), so the
// spinner the packages tree shows for this install can open its output on click (PY-91529).
packagingService.markInstalling(sdk, installKey, trace.uuid.toString())
refreshInstallControl()
packagingService.serviceScope.launch {
@@ -0,0 +1,37 @@
// 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.renderers
import com.intellij.idea.TestFor
import com.jetbrains.python.packaging.toolwindow.packages.tree.renderers.TrailingIconKind
import com.jetbrains.python.packaging.toolwindow.packages.tree.renderers.chooseInstallableTrailingIconKind
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
@TestFor(issues = ["PY-91529"])
internal class PyPackageTreeCellRendererTrailingIconTest {
@Test
fun `a running install outranks the hover state so the row shows a spinner, not an install icon`() {
val kind = chooseInstallableTrailingIconKind(isInstalling = true, showActions = true)
assertEquals(TrailingIconKind.PROGRESS, kind, "hovering a row whose install is running must not turn the spinner back into an action")
}
@Test
fun `the spinner is shown even when the row is not hovered`() {
val kind = chooseInstallableTrailingIconKind(isInstalling = true, showActions = false)
assertEquals(TrailingIconKind.PROGRESS, kind, "progress must stay visible regardless of hover state")
}
@Test
fun `hovering an idle row offers the install action`() {
val kind = chooseInstallableTrailingIconKind(isInstalling = false, showActions = true)
assertEquals(TrailingIconKind.ACTION, kind)
}
@Test
fun `no icon when nothing is running and the row is not hovered`() {
val kind = chooseInstallableTrailingIconKind(isInstalling = false, showActions = false)
assertNull(kind)
}
}
@@ -1,11 +1,13 @@
// 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.idea.TestFor
import com.intellij.openapi.util.UserDataHolderBase
import com.jetbrains.python.allure.Layers
import com.jetbrains.python.allure.Subsystems
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
@@ -62,4 +64,59 @@ internal class PyActiveInstallsTest {
assertTrue(PyActiveInstalls.of(sdkA).isPackageInstalling("numpy"))
assertFalse(PyActiveInstalls.of(sdkB).isPackageInstalling("numpy"))
}
@Test
@TestFor(issues = ["PY-91529"])
fun `mark keeps the trace uuid so the running command can be located`() {
val installs = PyActiveInstalls()
val key = PyActiveInstalls.packageKey("numpy")
assertTrue(installs.mark(key, "trace-1"))
assertEquals("trace-1", installs.traceUuid(key))
}
@Test
@TestFor(issues = ["PY-91529"])
fun `an install started without a trace has no uuid but still counts as installing`() {
val installs = PyActiveInstalls()
val key = PyActiveInstalls.packageKey("numpy")
assertTrue(installs.mark(key))
assertTrue(installs.isInstalling(key))
assertNull(installs.traceUuid(key))
}
@Test
@TestFor(issues = ["PY-91529"])
fun `unmark clears the uuid so it cannot leak into the next install of the same package`() {
val installs = PyActiveInstalls()
val key = PyActiveInstalls.packageKey("numpy")
installs.mark(key, "trace-1")
assertTrue(installs.unmark(key))
assertNull(installs.traceUuid(key))
// Re-installing the same package without a trace must not resurrect the previous run's uuid.
installs.mark(key)
assertNull(installs.traceUuid(key))
}
@Test
@TestFor(issues = ["PY-91529"])
fun `a rejected duplicate keeps the uuid of the install that is actually running`() {
val installs = PyActiveInstalls()
val key = PyActiveInstalls.packageKey("numpy")
assertTrue(installs.mark(key, "trace-1"))
assertFalse(installs.mark(key, "trace-2"))
assertEquals("trace-1", installs.traceUuid(key))
}
@Test
@TestFor(issues = ["PY-91529"])
fun `traceUuid of a key that is not installing is null`() {
val installs = PyActiveInstalls()
assertNull(installs.traceUuid(PyActiveInstalls.packageKey("numpy")))
}
}