From ee75dae5778f23f8b737fa36f428a9eef4c312d3 Mon Sep 17 00:00:00 2001 From: Jakub Kuczys Date: Fri, 10 Apr 2026 22:23:41 +0200 Subject: [PATCH 1/4] Fix incomplete support for stderr in Legacy Windows layer --- rich/_win32_console.py | 17 ++++++++++- rich/_windows.py | 13 +++++++-- rich/console.py | 65 +++++++++++++++++++++++------------------- 3 files changed, 61 insertions(+), 34 deletions(-) diff --git a/rich/_win32_console.py b/rich/_win32_console.py index 371ec09fac..a7106287c3 100644 --- a/rich/_win32_console.py +++ b/rich/_win32_console.py @@ -17,11 +17,26 @@ from ctypes import Structure, byref, wintypes from typing import IO, NamedTuple, Type, cast +from rich._fileno import get_fileno from rich.color import ColorSystem from rich.style import Style +try: + STDOUT_FILENO = sys.__stdout__.fileno() # type: ignore[union-attr] +except Exception: + STDOUT_FILENO = 1 +try: + STDERR_FILENO = sys.__stderr__.fileno() # type: ignore[union-attr] +except Exception: + STDERR_FILENO = 2 + STDOUT = -11 +STDERR = -12 ENABLE_VIRTUAL_TERMINAL_PROCESSING = 4 +FILENO_TO_HANDLE = { + STDOUT_FILENO: STDOUT, + STDERR_FILENO: STDERR, +} COORD = wintypes._COORD @@ -360,7 +375,7 @@ class LegacyWindowsTerm: ] def __init__(self, file: "IO[str]") -> None: - handle = GetStdHandle(STDOUT) + handle = GetStdHandle(FILENO_TO_HANDLE.get(get_fileno(file), STDOUT)) self._handle = handle default_text = GetConsoleScreenBufferInfo(handle).wAttributes self._default_text = default_text diff --git a/rich/_windows.py b/rich/_windows.py index e17c5c0fdc..0fe4e88ec9 100644 --- a/rich/_windows.py +++ b/rich/_windows.py @@ -1,5 +1,6 @@ import sys from dataclasses import dataclass +from typing import Optional @dataclass @@ -24,6 +25,8 @@ class WindowsConsoleFeatures: from rich._win32_console import ( ENABLE_VIRTUAL_TERMINAL_PROCESSING, + FILENO_TO_HANDLE, + STDOUT, GetConsoleMode, GetStdHandle, LegacyWindowsError, @@ -31,19 +34,23 @@ class WindowsConsoleFeatures: except (AttributeError, ImportError, ValueError): # Fallback if we can't load the Windows DLL - def get_windows_console_features() -> WindowsConsoleFeatures: + def get_windows_console_features( + fileno: Optional[int] = None, + ) -> WindowsConsoleFeatures: features = WindowsConsoleFeatures() return features else: - def get_windows_console_features() -> WindowsConsoleFeatures: + def get_windows_console_features( + fileno: Optional[int] = None, + ) -> WindowsConsoleFeatures: """Get windows console features. Returns: WindowsConsoleFeatures: An instance of WindowsConsoleFeatures. """ - handle = GetStdHandle() + handle = GetStdHandle(FILENO_TO_HANDLE.get(fileno, STDOUT)) try: console_mode = GetConsoleMode(handle) success = True diff --git a/rich/console.py b/rich/console.py index ad92d529c0..30b7ba93ba 100644 --- a/rich/console.py +++ b/rich/console.py @@ -569,19 +569,23 @@ def process_renderables( _windows_console_features: Optional["WindowsConsoleFeatures"] = None -def get_windows_console_features() -> "WindowsConsoleFeatures": # pragma: no cover +def get_windows_console_features( + file: Optional[IO[str]] = None, +) -> "WindowsConsoleFeatures": # pragma: no cover global _windows_console_features if _windows_console_features is not None: return _windows_console_features from ._windows import get_windows_console_features - _windows_console_features = get_windows_console_features() + fileno = get_fileno(file) if file is not None else None + + _windows_console_features = get_windows_console_features(fileno) return _windows_console_features -def detect_legacy_windows() -> bool: +def detect_legacy_windows(file: Optional[IO[str]] = None) -> bool: """Detect legacy Windows.""" - return WINDOWS and not get_windows_console_features().vt + return WINDOWS and not get_windows_console_features(file).vt class Console: @@ -681,24 +685,6 @@ def __init__( self._emoji = emoji self._emoji_variant: Optional[EmojiVariant] = emoji_variant self._highlight = highlight - self.legacy_windows: bool = ( - (detect_legacy_windows() and not self.is_jupyter) - if legacy_windows is None - else legacy_windows - ) - - if width is None: - columns = self._environ.get("COLUMNS") - if columns is not None and columns.isdigit(): - width = int(columns) - self.legacy_windows - if height is None: - lines = self._environ.get("LINES") - if lines is not None and lines.isdigit(): - height = int(lines) - - self.soft_wrap = soft_wrap - self._width = width - self._height = height self._color_system: Optional[ColorSystem] @@ -710,13 +696,6 @@ def __init__( self.quiet = quiet self.stderr = stderr - if color_system is None: - self._color_system = None - elif color_system == "auto": - self._color_system = self._detect_color_system() - else: - self._color_system = COLOR_SYSTEMS[color_system] - self._lock = threading.RLock() self._log_render = LogRender( show_time=log_time, @@ -756,6 +735,32 @@ def __init__( self._live_stack: List[Live] = [] self._is_alt_screen = False + self.legacy_windows: bool = ( + (detect_legacy_windows(self.file) and not self.is_jupyter) + if legacy_windows is None + else legacy_windows + ) + + if width is None: + columns = self._environ.get("COLUMNS") + if columns is not None and columns.isdigit(): + width = int(columns) - self.legacy_windows + if height is None: + lines = self._environ.get("LINES") + if lines is not None and lines.isdigit(): + height = int(lines) + + self.soft_wrap = soft_wrap + self._width = width + self._height = height + + if color_system is None: + self._color_system = None + elif color_system == "auto": + self._color_system = self._detect_color_system() + else: + self._color_system = COLOR_SYSTEMS[color_system] + def __repr__(self) -> str: return f"" @@ -801,7 +806,7 @@ def _detect_color_system(self) -> Optional[ColorSystem]: if WINDOWS: # pragma: no cover if self.legacy_windows: # pragma: no cover return ColorSystem.WINDOWS - windows_console_features = get_windows_console_features() + windows_console_features = get_windows_console_features(self.file) return ( ColorSystem.TRUECOLOR if windows_console_features.truecolor From 3d0ddd5c5185171933ede40df8398e88d8fcb9bb Mon Sep 17 00:00:00 2001 From: Jakub Kuczys Date: Fri, 10 Apr 2026 22:36:15 +0200 Subject: [PATCH 2/4] Add self to CONTRIBUTORS.md --- CONTRIBUTORS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 4d77a0e3ed..003bf51d0c 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -100,3 +100,4 @@ The following people have contributed to the development of Rich: - [Brandon Capener](https://github.com/bcapener) - [Alex Zheng](https://github.com/alexzheng111) - [Sebastian Speitel](https://github.com/SebastianSpeitel) +- [Jakub Kuczys](https://github.com/Jackenmen) From 9506abcba576d61ed52eed51029dfd2dbbe9847b Mon Sep 17 00:00:00 2001 From: Jakub Kuczys Date: Fri, 10 Apr 2026 22:53:00 +0200 Subject: [PATCH 3/4] Add changelog entries --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bdd4f15974..cbe5b252c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +### Changed + +- `console.get_windows_console_features` and `console.detect_legacy_windows` now have an optional `file` parameter https://github.com/Textualize/rich/pull/4072 + +### Fixed + +- Fixed the auto-detection of `Console.legacy_windows` for the stderr stream, when stdout is redirected https://github.com/Textualize/rich/pull/4072 +- Fixed legacy Windows rendering for the stderr stream https://github.com/Textualize/rich/pull/4072 + ## [14.3.3] - 2026-02-19 ### Fixed From b159cea2215cd4d2dbf3d21fac61a6eaabd62ee5 Mon Sep 17 00:00:00 2001 From: Jakub Kuczys Date: Fri, 10 Apr 2026 23:18:25 +0200 Subject: [PATCH 4/4] Fix platform-specific typing issue --- rich/_win32_console.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/rich/_win32_console.py b/rich/_win32_console.py index a7106287c3..566a0437fc 100644 --- a/rich/_win32_console.py +++ b/rich/_win32_console.py @@ -10,7 +10,19 @@ windll: Any = None if sys.platform == "win32": windll = ctypes.LibraryLoader(ctypes.WinDLL) + # `type: ignore` is needed only on Windows and mypy reports unused-ignore when not in an if + try: + STDOUT_FILENO = sys.__stdout__.fileno() # type: ignore[union-attr] + except Exception: + STDOUT_FILENO = 1 + try: + STDERR_FILENO = sys.__stderr__.fileno() # type: ignore[union-attr] + except Exception: + STDERR_FILENO = 2 else: + # mypy does not realize that anything past the raise is unreachable and reports undefined name + STDOUT_FILENO = 1 + STDERR_FILENO = 2 raise ImportError(f"{__name__} can only be imported on Windows") import time @@ -21,15 +33,6 @@ from rich.color import ColorSystem from rich.style import Style -try: - STDOUT_FILENO = sys.__stdout__.fileno() # type: ignore[union-attr] -except Exception: - STDOUT_FILENO = 1 -try: - STDERR_FILENO = sys.__stderr__.fileno() # type: ignore[union-attr] -except Exception: - STDERR_FILENO = 2 - STDOUT = -11 STDERR = -12 ENABLE_VIRTUAL_TERMINAL_PROCESSING = 4