From 97d20472b8c329db353afc01c7d16579d96e07b7 Mon Sep 17 00:00:00 2001 From: Manfred Riem Date: Thu, 11 Jun 2026 08:23:03 -0500 Subject: [PATCH 1/5] 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 227f0f975e..0aa1518b2d 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -381,8 +381,12 @@ def init( ]: tracker.add(key, label) + # 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=True + tracker.render(), console=console, refresh_per_second=8, transient=_transient ) as live: tracker.attach_refresh(lambda: live.update(tracker.render())) try: 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 From ac1b50f9065564815741fd053ae7ff5859c7f002 Mon Sep 17 00:00:00 2001 From: Manfred Riem Date: Tue, 16 Jun 2026 06:51:46 -0500 Subject: [PATCH 2/5] test: address review feedback on test quality - Use captured['transient'] instead of .get() for clearer KeyError on failure - Source guards now assert both the platform check AND transient=_transient usage - Remove unused imports (MagicMock retained as it's used, removed pytest) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/test_live_transient_windows.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/test_live_transient_windows.py b/tests/test_live_transient_windows.py index 8a5dfb5aaf..157aca1ce2 100644 --- a/tests/test_live_transient_windows.py +++ b/tests/test_live_transient_windows.py @@ -44,7 +44,7 @@ def fake_live(*args, **kwargs): select_with_arrows({"a": "Option A", "b": "Option B"}, "Pick one", "a") - return captured.get("transient") + return captured["transient"] class TestSelectWithArrowsLiveTransient: @@ -66,16 +66,18 @@ def test_transient_true_on_macos(self): class TestSourceContainsPlatformGuard: - """Ensure the platform guard is present in source (prevents regression).""" + """Ensure the platform guard and its usage in Live() are present in source.""" def test_init_has_win32_guard(self): - """init.py must contain the win32 platform check for transient.""" + """init.py must check platform and pass transient to Live.""" 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 + assert 'sys.platform != "win32"' in content + assert "transient=_transient" in content def test_console_has_win32_guard(self): - """_console.py must contain the win32 platform check for transient.""" + """_console.py must check platform and pass transient to Live.""" 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 + assert 'sys.platform != "win32"' in content + assert "transient=_transient" in content From 51df5edc3627a19ba16fb96c91b1f6cf1654ebc6 Mon Sep 17 00:00:00 2001 From: Manfred Riem Date: Tue, 16 Jun 2026 14:53:43 -0500 Subject: [PATCH 3/5] test: use regex in source guards for resilience to formatting changes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/test_live_transient_windows.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/test_live_transient_windows.py b/tests/test_live_transient_windows.py index 157aca1ce2..5ac9bd96f1 100644 --- a/tests/test_live_transient_windows.py +++ b/tests/test_live_transient_windows.py @@ -70,14 +70,19 @@ class TestSourceContainsPlatformGuard: def test_init_has_win32_guard(self): """init.py must check platform and pass transient to Live.""" + import re + init_src = Path(__file__).resolve().parent.parent / "src" / "specify_cli" / "commands" / "init.py" content = init_src.read_text(encoding="utf-8") - assert 'sys.platform != "win32"' in content - assert "transient=_transient" in content + assert re.search(r"sys\.platform\s*!=\s*['\"]win32['\"]", content) + assert re.search(r"transient\s*=\s*_transient", content) def test_console_has_win32_guard(self): """_console.py must check platform and pass transient to Live.""" + import re + console_src = Path(__file__).resolve().parent.parent / "src" / "specify_cli" / "_console.py" content = console_src.read_text(encoding="utf-8") - assert 'sys.platform != "win32"' in content + assert re.search(r"sys\.platform\s*!=\s*['\"]win32['\"]", content) + assert re.search(r"transient\s*=\s*_transient", content) assert "transient=_transient" in content From a1d86ad35330b29ebe198c710485b41329734f99 Mon Sep 17 00:00:00 2001 From: Manfred Riem Date: Tue, 16 Jun 2026 16:55:33 -0500 Subject: [PATCH 4/5] test: use single DOTALL regex to verify assignment flows into Live() Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/test_live_transient_windows.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/test_live_transient_windows.py b/tests/test_live_transient_windows.py index 5ac9bd96f1..b79c3be88f 100644 --- a/tests/test_live_transient_windows.py +++ b/tests/test_live_transient_windows.py @@ -66,23 +66,25 @@ def test_transient_true_on_macos(self): class TestSourceContainsPlatformGuard: - """Ensure the platform guard and its usage in Live() are present in source.""" + """Ensure the platform guard feeds into the Live() transient kwarg.""" + + # Single DOTALL regex: _transient assigned from win32 check, then used in Live() + _GUARD_RE = r"_transient\s*=\s*sys\.platform\s*!=\s*['\"]win32['\"].*Live\(.*transient\s*=\s*_transient" def test_init_has_win32_guard(self): - """init.py must check platform and pass transient to Live.""" + """init.py must assign _transient from platform check and pass it to Live.""" import re init_src = Path(__file__).resolve().parent.parent / "src" / "specify_cli" / "commands" / "init.py" content = init_src.read_text(encoding="utf-8") - assert re.search(r"sys\.platform\s*!=\s*['\"]win32['\"]", content) - assert re.search(r"transient\s*=\s*_transient", content) + assert re.search(self._GUARD_RE, content, re.DOTALL) def test_console_has_win32_guard(self): - """_console.py must check platform and pass transient to Live.""" + """_console.py must assign _transient from platform check and pass it to Live.""" import re console_src = Path(__file__).resolve().parent.parent / "src" / "specify_cli" / "_console.py" content = console_src.read_text(encoding="utf-8") - assert re.search(r"sys\.platform\s*!=\s*['\"]win32['\"]", content) + assert re.search(self._GUARD_RE, content, re.DOTALL) assert re.search(r"transient\s*=\s*_transient", content) assert "transient=_transient" in content From 7aec345935833be76cf46ee72136a11019052c75 Mon Sep 17 00:00:00 2001 From: Manfred Riem Date: Tue, 16 Jun 2026 18:00:11 -0500 Subject: [PATCH 5/5] fix: skip duplicate tracker print on Windows when transient=False When transient is False, Rich leaves the Live output on screen. The subsequent console.print(tracker.render()) would duplicate it. Gate it behind _transient so Windows users see the tracker exactly once. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/commands/init.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/commands/init.py b/src/specify_cli/commands/init.py index 0aa1518b2d..76514caa82 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -656,7 +656,8 @@ def init( finally: pass - console.print(tracker.render()) + if _transient: + console.print(tracker.render()) console.print("\n[bold green]Project ready.[/bold green]") agent_config = AGENT_CONFIG.get(selected_ai)