From 984efd83b1da7c66b1537e65d5ae573a0b7d1c88 Mon Sep 17 00:00:00 2001 From: Manfred Riem Date: Thu, 11 Jun 2026 08:23:03 -0500 Subject: [PATCH] fix: disable Rich Live transient mode on Windows to prevent PS 5.1 hang PowerShell 5.1's legacy console host does not reliably support VT escape sequences. Rich's Live(transient=True) attempts cursor restoration on context exit, which hangs indefinitely on that console. Set transient=False when sys.platform == 'win32' in both init.py (progress tracker) and _console.py (select_with_arrows). The only cosmetic effect is that progress output remains visible after completion on Windows. Fixes #2927 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/_console.py | 4 +- src/specify_cli/commands/init.py | 6 ++- tests/test_live_transient_windows.py | 81 ++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 tests/test_live_transient_windows.py diff --git a/src/specify_cli/_console.py b/src/specify_cli/_console.py index 85229e4c5c..33bd70f77f 100644 --- a/src/specify_cli/_console.py +++ b/src/specify_cli/_console.py @@ -7,6 +7,7 @@ """ from __future__ import annotations +import sys from collections.abc import Callable import readchar @@ -192,7 +193,8 @@ def create_selection_panel(): def run_selection_loop(): nonlocal selected_key, selected_index - with Live(create_selection_panel(), console=console, transient=True, auto_refresh=False) as live: + _transient = sys.platform != "win32" + with Live(create_selection_panel(), console=console, transient=_transient, auto_refresh=False) as live: while True: try: key = get_key() diff --git a/src/specify_cli/commands/init.py b/src/specify_cli/commands/init.py index 8307bb7cf8..b50f664b67 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -294,7 +294,11 @@ def init( ]: tracker.add(key, label) - with Live(tracker.render(), console=console, refresh_per_second=8, transient=True) as live: + # Disable transient mode on Windows: PowerShell 5.1's legacy console + # hangs when Rich tries to restore cursor state via VT escape sequences. + _transient = sys.platform != "win32" + + with Live(tracker.render(), console=console, refresh_per_second=8, transient=_transient) as live: tracker.attach_refresh(lambda: live.update(tracker.render())) try: from ..integrations.manifest import IntegrationManifest diff --git a/tests/test_live_transient_windows.py b/tests/test_live_transient_windows.py new file mode 100644 index 0000000000..8a5dfb5aaf --- /dev/null +++ b/tests/test_live_transient_windows.py @@ -0,0 +1,81 @@ +"""Tests for Rich Live transient=False on Windows (GitHub issue #2927). + +PowerShell 5.1's legacy console host does not support VT escape sequences +reliably. Rich's ``Live(transient=True)`` attempts cursor restoration on +exit, which hangs indefinitely on that console. The fix disables transient +mode when ``sys.platform == "win32"``. + +These tests patch ``sys.platform`` and intercept the ``Live`` constructor +to verify the correct ``transient`` value reaches Rich. +""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock, patch + + +# --------------------------------------------------------------------------- +# _console.py — Live in the select_with_arrows helper +# --------------------------------------------------------------------------- + + +def _invoke_select_with_arrows(platform: str) -> bool: + """Patch sys.platform and Live, invoke select_with_arrows, return transient kwarg.""" + captured = {} + + mock_live_instance = MagicMock() + mock_live_instance.__enter__ = MagicMock(return_value=mock_live_instance) + mock_live_instance.__exit__ = MagicMock(return_value=False) + + def fake_live(*args, **kwargs): + captured.update(kwargs) + return mock_live_instance + + # Patch readchar so the loop immediately returns "enter" + import readchar + + with ( + patch("sys.platform", platform), + patch("specify_cli._console.Live", side_effect=fake_live), + patch("specify_cli._console.readchar.readkey", return_value=readchar.key.ENTER), + ): + from specify_cli._console import select_with_arrows + + select_with_arrows({"a": "Option A", "b": "Option B"}, "Pick one", "a") + + return captured.get("transient") + + +class TestSelectWithArrowsLiveTransient: + """Verify that select_with_arrows passes transient=False on Windows.""" + + def test_transient_false_on_windows(self): + assert _invoke_select_with_arrows("win32") is False + + def test_transient_true_on_linux(self): + assert _invoke_select_with_arrows("linux") is True + + def test_transient_true_on_macos(self): + assert _invoke_select_with_arrows("darwin") is True + + +# --------------------------------------------------------------------------- +# init.py — verify source contains the platform guard (regression check) +# --------------------------------------------------------------------------- + + +class TestSourceContainsPlatformGuard: + """Ensure the platform guard is present in source (prevents regression).""" + + def test_init_has_win32_guard(self): + """init.py must contain the win32 platform check for transient.""" + init_src = Path(__file__).resolve().parent.parent / "src" / "specify_cli" / "commands" / "init.py" + content = init_src.read_text(encoding="utf-8") + assert '_transient = sys.platform != "win32"' in content + + def test_console_has_win32_guard(self): + """_console.py must contain the win32 platform check for transient.""" + console_src = Path(__file__).resolve().parent.parent / "src" / "specify_cli" / "_console.py" + content = console_src.read_text(encoding="utf-8") + assert '_transient = sys.platform != "win32"' in content