diff --git a/python/pluginResources/messages/PyBundle.properties b/python/pluginResources/messages/PyBundle.properties index e7531cc0a163..4ab2db6d8e2e 100644 --- a/python/pluginResources/messages/PyBundle.properties +++ b/python/pluginResources/messages/PyBundle.properties @@ -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 diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/PyActiveInstalls.kt b/python/src/com/jetbrains/python/packaging/toolwindow/PyActiveInstalls.kt index e9f802af03af..268f8c3d6ed3 100644 --- a/python/src/com/jetbrains/python/packaging/toolwindow/PyActiveInstalls.kt +++ b/python/src/com/jetbrains/python/packaging/toolwindow/PyActiveInstalls.kt @@ -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 = ConcurrentHashMap.newKeySet() + private val installs: ConcurrentHashMap = 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::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. */ diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingToolWindowService.kt b/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingToolWindowService.kt index 4b3306eef5ea..4e7de0cdddcf 100644 --- a/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingToolWindowService.kt +++ b/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingToolWindowService.kt @@ -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 = 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( diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/packages/tree/PyPackagesTree.kt b/python/src/com/jetbrains/python/packaging/toolwindow/packages/tree/PyPackagesTree.kt index 26bd0951e74e..851ef366e94a 100644 --- a/python/src/com/jetbrains/python/packaging/toolwindow/packages/tree/PyPackagesTree.kt +++ b/python/src/com/jetbrains/python/packaging/toolwindow/packages/tree/PyPackagesTree.kt @@ -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) diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/packages/tree/renderers/PyPackageTreeCellRenderer.kt b/python/src/com/jetbrains/python/packaging/toolwindow/packages/tree/renderers/PyPackageTreeCellRenderer.kt index d33c904d0208..203819867479 100644 --- a/python/src/com/jetbrains/python/packaging/toolwindow/packages/tree/renderers/PyPackageTreeCellRenderer.kt +++ b/python/src/com/jetbrains/python/packaging/toolwindow/packages/tree/renderers/PyPackageTreeCellRenderer.kt @@ -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 +} + diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/ui/PyInstallPackageDialog.kt b/python/src/com/jetbrains/python/packaging/toolwindow/ui/PyInstallPackageDialog.kt index 2a242705c0fc..d57cb8446637 100644 --- a/python/src/com/jetbrains/python/packaging/toolwindow/ui/PyInstallPackageDialog.kt +++ b/python/src/com/jetbrains/python/packaging/toolwindow/ui/PyInstallPackageDialog.kt @@ -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 { diff --git a/python/testSrc/com/intellij/python/junit5Tests/unit/packaging/renderers/PyPackageTreeCellRendererTrailingIconTest.kt b/python/testSrc/com/intellij/python/junit5Tests/unit/packaging/renderers/PyPackageTreeCellRendererTrailingIconTest.kt new file mode 100644 index 000000000000..c796c790f0e0 --- /dev/null +++ b/python/testSrc/com/intellij/python/junit5Tests/unit/packaging/renderers/PyPackageTreeCellRendererTrailingIconTest.kt @@ -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) + } +} diff --git a/python/testSrc/com/jetbrains/python/packaging/toolwindow/PyActiveInstallsTest.kt b/python/testSrc/com/jetbrains/python/packaging/toolwindow/PyActiveInstallsTest.kt index 513e68656d17..6ad53f6ff28b 100644 --- a/python/testSrc/com/jetbrains/python/packaging/toolwindow/PyActiveInstallsTest.kt +++ b/python/testSrc/com/jetbrains/python/packaging/toolwindow/PyActiveInstallsTest.kt @@ -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"))) + } }