diff --git a/CHANGELOG.md b/CHANGELOG.md index ab758b1ce..87af64595 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,10 +11,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Dropped support for Python3.9 +- `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 empty print ignoring the `end` parameter +- 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.4] - 2026-04-11 diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 2155a42a4..f46d94900 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -101,3 +101,4 @@ The following people have contributed to the development of Rich: - [Alex Zheng](https://github.com/alexzheng111) - [Sebastian Speitel](https://github.com/SebastianSpeitel) - [Kevin Turcios](https://github.com/KRRT7) +- [Jakub Kuczys](https://github.com/Jackenmen) diff --git a/rich/_win32_console.py b/rich/_win32_console.py index 371ec09fa..566a0437f 100644 --- a/rich/_win32_console.py +++ b/rich/_win32_console.py @@ -10,18 +10,36 @@ 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 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 STDOUT = -11 +STDERR = -12 ENABLE_VIRTUAL_TERMINAL_PROCESSING = 4 +FILENO_TO_HANDLE = { + STDOUT_FILENO: STDOUT, + STDERR_FILENO: STDERR, +} COORD = wintypes._COORD @@ -360,7 +378,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 e17c5c0fd..0fe4e88ec 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 0bdce7698..29c0097f5 100644 --- a/rich/console.py +++ b/rich/console.py @@ -563,19 +563,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: @@ -675,24 +679,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] @@ -704,13 +690,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, @@ -750,6 +729,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"" @@ -795,7 +800,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