Repository: Textualize/rich

Issue:
[BUG] Rich 12.0.0 fails to write to stdout attached to a pipe on Windows 
### Describe the bug

Since rich 12.0.0, printing to stdout fails running under a subprocess attached to a pipe on Windows. 

To reproduce, save the following reproducer and run it first with rich==12.0.0

```python
import subprocess
import sys

code = """\
import sys
from rich.console import Console

console = Console(file=sys.stdout)
console.print("spam")
"""
err_code = code.replace("sys.stdout", "sys.stderr")
cmd = [sys.executable, "-"]

print("[capture_output=True via sys.stdout]")
proc = subprocess.run(cmd, input=code, capture_output=True, encoding="utf-8")
print(f"{proc.stdout=} {proc.stderr=}")

print("[capture_output=True via sys.stderr]")
proc = subprocess.run(cmd, input=err_code, capture_output=True, encoding="utf-8")
print(f"{proc.stdout=} {proc.stderr=}")

print("[capture_output=False via sys.stdout]")
proc = subprocess.run(cmd, input=code, capture_output=False, encoding="utf-8")
```

![image](https://user-images.githubusercontent.com/63936253/158076766-068ccd07-0ee2-463a-8482-74636297bd44.png)

There's a few observations here:

- With rich==12.0.0 installed, the captured stdout is empty. With an older version it works fine (see orange / blue)
- Standard error is completely unaffected. It's also fine letting the subprocess inherit the streams (see green)

### Platform

**Python:** CPython 3.8.5
**OS:** Windows 10 Home 21H2 (build 19044.1526)
**Terminal:** Windows Terminal 1.11.3471.0

<details>
<summary>rich.diagnose</summary>

```
(venv) PS R:\Programming\diff-shades> python -m rich.diagnose
╭───────────────────────── <class 'rich.console.Console'> ─────────────────────────╮
│ A high level console interface.                                                  │
│                                                                                  │
│ ╭──────────────────────────────────────────────────────────────────────────────╮ │
│ │ <console width=148 ColorSystem.TRUECOLOR>                                    │ │
│ ╰──────────────────────────────────────────────────────────────────────────────╯ │
│                                                                                  │
│     color_system = 'truecolor'                                                   │
│         encoding = 'utf-8'                                                       │
│             file = <_io.TextIOWrapper name='<stdout>' mode='w' encoding='utf-8'> │
│           height = 35                                                            │
│    is_alt_screen = False                                                         │
│ is_dumb_terminal = False                                                         │
│   is_interactive = True                                                          │
│       is_jupyter = False                                                         │
│      is_terminal = True                                                          │
│   legacy_windows = False                                                         │
│         no_color = False                                                         │
│          options = ConsoleOptions(                                               │
│                        size=ConsoleDimensions(width=148, height=35),             │
│                        legacy_windows=False,                                     │
│                        min_width=1,                                              │
│                        max_width=148,                                            │
│                        is_terminal=True,                                         │
│                        encoding='utf-8',                                         │
│                        max_height=35,                                            │
│                        justify=None,                                             │
│                        overflow=None,                                            │
│                        no_wrap=False,                                            │
│                        highlight=None,                                           │
│                        markup=None,                                              │
│                        height=None                                               │
│                    )                                                             │
│            quiet = False                                                         │
│           record = False                                                         │
│         safe_box = True                                                          │
│             size = ConsoleDimensions(width=148, height=35)                       │
│        soft_wrap = False                                                         │
│           stderr = False                                                         │
│            style = None                                                          │
│         tab_size = 8                                                             │
│            width = 148                                                           │
╰──────────────────────────────────────────────────────────────────────────────────╯
╭── <class 'rich._windows.WindowsConsoleFeatures'> ───╮
│ Windows features available.                         │
│                                                     │
│ ╭─────────────────────────────────────────────────╮ │
│ │ WindowsConsoleFeatures(vt=True, truecolor=True) │ │
│ ╰─────────────────────────────────────────────────╯ │
│                                                     │
│ truecolor = True                                    │
│        vt = True                                    │
╰─────────────────────────────────────────────────────╯
╭────── Environment Variables ───────╮
│ {                                  │
│     'TERM': None,                  │
│     'COLORTERM': None,             │
│     'CLICOLOR': None,              │
│     'NO_COLOR': None,              │
│     'TERM_PROGRAM': None,          │
│     'COLUMNS': None,               │
│     'LINES': None,                 │
│     'JPY_PARENT_PID': None,        │
│     'VSCODE_VERBOSE_LOGGING': None │
│ }                                  │
╰────────────────────────────────────╯
platform="Windows"
```

</details>

### Additional context

[This was discovered by GHA Windows CI](https://github.com/ichard26/diff-shades/runs/5528992639?check_suite_focus=true#step:5:235) (the problem is hidden and buried in this test, but this shows it has been reproduced under several environments[^1]). Original investigation into this strange behaviour happened in [the #black-formatter channel on Python Discord](https://discord.com/channels/267624335836053506/846434317021741086/952628086535520367).

According to @jack1142 who helped me investigate it, it's related to the recent move to replace colorama with first-party code that interacts with the Windows APIs:

> I'm pretty sure it's because it can't use WriteConsole API (the one that's used for legacy console printing) when it's piped because output from WriteConsole is not capturable 

They then shared this excerpt from Windows console API documentation:

> WriteConsole fails if it is used with a standard handle that is redirected to a file. If an application processes multilingual output that can be redirected, determine whether the output handle is a console handle (one method is to call the GetConsoleMode function and check whether it succeeds). If the handle is a console handle, call WriteConsole. If the handle is not a console handle, the output is redirected and you should call WriteFile to perform the I/O.
https://docs.microsoft.com/en-us/windows/console/writeconsole 

[^1]: if you're curious, it's basically testing whether recording the output (via `Console(record=True)`) and then dumping it as HTML works as expected with the rest of the tool


Discussion:
Thanks. Will take a look.

Resolving patch:
diff --git a/rich/_win32_console.py b/rich/_win32_console.py
--- a/rich/_win32_console.py
+++ b/rich/_win32_console.py
@@ -302,40 +302,6 @@ def SetConsoleTitle(title: str) -> bool:
     return bool(_SetConsoleTitle(title))
 
 
-_WriteConsole = windll.kernel32.WriteConsoleW
-_WriteConsole.argtypes = [
-    wintypes.HANDLE,
-    wintypes.LPWSTR,
-    wintypes.DWORD,
-    wintypes.LPDWORD,
-    wintypes.LPVOID,
-]
-_WriteConsole.restype = wintypes.BOOL
-
-
-def WriteConsole(std_handle: wintypes.HANDLE, text: str) -> bool:
-    """Write a string of text to the console, starting at the current cursor position
-
-    Args:
-        std_handle (wintypes.HANDLE): A handle to the console input buffer or the console screen buffer.
-        text (str): The text to write.
-
-    Returns:
-        bool: True if the function succeeds, otherwise False.
-    """
-    buffer = wintypes.LPWSTR(text)
-    num_chars_written = wintypes.LPDWORD()
-    return bool(
-        _WriteConsole(
-            std_handle,
-            buffer,
-            wintypes.DWORD(len(text)),
-            num_chars_written,
-            wintypes.LPVOID(None),
-        )
-    )
-
-
 class LegacyWindowsTerm:
     """This class allows interaction with the legacy Windows Console API. It should only be used in the context
     of environments where virtual terminal processing is not available. However, if it is used in a Windows environment,
@@ -367,7 +333,7 @@ class LegacyWindowsTerm:
         15,  # bright white
     ]
 
-    def __init__(self) -> None:
+    def __init__(self, file: IO[str]) -> None:
         handle = GetStdHandle(STDOUT)
         self._handle = handle
         default_text = GetConsoleScreenBufferInfo(handle).wAttributes
@@ -377,6 +343,10 @@ def __init__(self) -> None:
         self._default_back = (default_text >> 4) & 7
         self._default_attrs = self._default_fore | (self._default_back << 4)
 
+        self._file = file
+        self.write = file.write
+        self.flush = file.flush
+
     @property
     def cursor_position(self) -> WindowsCoordinates:
         """Returns the current position of the cursor (0-based)
@@ -405,7 +375,8 @@ def write_text(self, text: str) -> None:
         Args:
             text (str): The text to write to the console
         """
-        WriteConsole(self._handle, text)
+        self.write(text)
+        self.flush()
 
     def write_styled(self, text: str, style: Style) -> None:
         """Write styled text to the terminal.
@@ -576,7 +547,7 @@ def set_title(self, title: str) -> None:
 
     console = Console()
 
-    term = LegacyWindowsTerm()
+    term = LegacyWindowsTerm(sys.stdout)
     term.set_title("Win32 Console Examples")
 
     style = Style(color="black", bgcolor="red")
diff --git a/rich/console.py b/rich/console.py
--- a/rich/console.py
+++ b/rich/console.py
@@ -1932,7 +1932,10 @@ def _check_buffer(self) -> None:
                             from rich._win32_console import LegacyWindowsTerm
                             from rich._windows_renderer import legacy_windows_render
 
-                            legacy_windows_render(self._buffer[:], LegacyWindowsTerm())
+                            with open(file_no, "w") as output_file:
+                                legacy_windows_render(
+                                    self._buffer[:], LegacyWindowsTerm(output_file)
+                                )
 
                         output_capture_enabled = bool(self._buffer_index)
                         if not legacy_windows_stdout or output_capture_enabled:


Test patch:
diff --git a/tests/test_win32_console.py b/tests/test_win32_console.py
--- a/tests/test_win32_console.py
+++ b/tests/test_win32_console.py
@@ -41,47 +41,43 @@ def win32_handle():
         _win32_console, "GetConsoleScreenBufferInfo", return_value=StubScreenBufferInfo
     )
     def test_cursor_position(_):
-        term = LegacyWindowsTerm()
+        term = LegacyWindowsTerm(sys.stdout)
         assert term.cursor_position == WindowsCoordinates(row=CURSOR_Y, col=CURSOR_X)
 
     @patch.object(
         _win32_console, "GetConsoleScreenBufferInfo", return_value=StubScreenBufferInfo
     )
     def test_screen_size(_):
-        term = LegacyWindowsTerm()
+        term = LegacyWindowsTerm(sys.stdout)
         assert term.screen_size == WindowsCoordinates(
             row=SCREEN_HEIGHT, col=SCREEN_WIDTH
         )
 
-    @patch.object(_win32_console, "WriteConsole", return_value=True)
     @patch.object(
         _win32_console, "GetConsoleScreenBufferInfo", return_value=StubScreenBufferInfo
     )
-    def test_write_text(_, WriteConsole, win32_handle):
+    def test_write_text(_, win32_handle, capsys):
         text = "Hello, world!"
-        term = LegacyWindowsTerm()
+        term = LegacyWindowsTerm(sys.stdout)
 
         term.write_text(text)
 
-        WriteConsole.assert_called_once_with(win32_handle, text)
+        captured = capsys.readouterr()
+        assert captured.out == text
 
-    @patch.object(_win32_console, "WriteConsole", return_value=True)
     @patch.object(_win32_console, "SetConsoleTextAttribute")
     @patch.object(
         _win32_console, "GetConsoleScreenBufferInfo", return_value=StubScreenBufferInfo
     )
-    def test_write_styled(_, SetConsoleTextAttribute, WriteConsole, win32_handle):
+    def test_write_styled(_, SetConsoleTextAttribute, win32_handle, capsys):
         style = Style.parse("black on red")
         text = "Hello, world!"
-        term = LegacyWindowsTerm()
+        term = LegacyWindowsTerm(sys.stdout)
 
         term.write_styled(text, style)
 
-        # Check that we've called the Console API to write the text
-        call_args = WriteConsole.call_args_list
-        assert len(call_args) == 1
-        args, _ = call_args[0]
-        assert args == (win32_handle, text)
+        captured = capsys.readouterr()
+        assert captured.out == text
 
         # Ensure we set the text attributes and then reset them after writing styled text
         call_args = SetConsoleTextAttribute.call_args_list
@@ -94,15 +90,14 @@ def test_write_styled(_, SetConsoleTextAttribute, WriteConsole, win32_handle):
         assert second_args == (win32_handle,)
         assert second_kwargs["attributes"] == DEFAULT_STYLE_ATTRIBUTE
 
-    @patch.object(_win32_console, "WriteConsole", return_value=True)
     @patch.object(_win32_console, "SetConsoleTextAttribute")
     @patch.object(
         _win32_console, "GetConsoleScreenBufferInfo", return_value=StubScreenBufferInfo
     )
-    def test_write_styled_bold(_, SetConsoleTextAttribute, __, win32_handle):
+    def test_write_styled_bold(_, SetConsoleTextAttribute, win32_handle):
         style = Style.parse("bold black on red")
         text = "Hello, world!"
-        term = LegacyWindowsTerm()
+        term = LegacyWindowsTerm(sys.stdout)
 
         term.write_styled(text, style)
 
@@ -113,15 +108,14 @@ def test_write_styled_bold(_, SetConsoleTextAttribute, __, win32_handle):
         assert first_args == (win32_handle,)
         assert first_kwargs["attributes"].value == expected_attr
 
-    @patch.object(_win32_console, "WriteConsole", return_value=True)
     @patch.object(_win32_console, "SetConsoleTextAttribute")
     @patch.object(
         _win32_console, "GetConsoleScreenBufferInfo", return_value=StubScreenBufferInfo
     )
-    def test_write_styled_reverse(_, SetConsoleTextAttribute, __, win32_handle):
+    def test_write_styled_reverse(_, SetConsoleTextAttribute, win32_handle):
         style = Style.parse("reverse red on blue")
         text = "Hello, world!"
-        term = LegacyWindowsTerm()
+        term = LegacyWindowsTerm(sys.stdout)
 
         term.write_styled(text, style)
 
@@ -132,15 +126,14 @@ def test_write_styled_reverse(_, SetConsoleTextAttribute, __, win32_handle):
         assert first_args == (win32_handle,)
         assert first_kwargs["attributes"].value == expected_attr
 
-    @patch.object(_win32_console, "WriteConsole", return_value=True)
     @patch.object(_win32_console, "SetConsoleTextAttribute")
     @patch.object(
         _win32_console, "GetConsoleScreenBufferInfo", return_value=StubScreenBufferInfo
     )
-    def test_write_styled_reverse(_, SetConsoleTextAttribute, __, win32_handle):
+    def test_write_styled_reverse(_, SetConsoleTextAttribute, win32_handle):
         style = Style.parse("dim bright_red on blue")
         text = "Hello, world!"
-        term = LegacyWindowsTerm()
+        term = LegacyWindowsTerm(sys.stdout)
 
         term.write_styled(text, style)
 
@@ -151,17 +144,14 @@ def test_write_styled_reverse(_, SetConsoleTextAttribute, __, win32_handle):
         assert first_args == (win32_handle,)
         assert first_kwargs["attributes"].value == expected_attr
 
-    @patch.object(_win32_console, "WriteConsole", return_value=True)
     @patch.object(_win32_console, "SetConsoleTextAttribute")
     @patch.object(
         _win32_console, "GetConsoleScreenBufferInfo", return_value=StubScreenBufferInfo
     )
-    def test_write_styled_no_foreground_color(
-        _, SetConsoleTextAttribute, __, win32_handle
-    ):
+    def test_write_styled_no_foreground_color(_, SetConsoleTextAttribute, win32_handle):
         style = Style.parse("on blue")
         text = "Hello, world!"
-        term = LegacyWindowsTerm()
+        term = LegacyWindowsTerm(sys.stdout)
 
         term.write_styled(text, style)
 
@@ -172,17 +162,14 @@ def test_write_styled_no_foreground_color(
         assert first_args == (win32_handle,)
         assert first_kwargs["attributes"].value == expected_attr
 
-    @patch.object(_win32_console, "WriteConsole", return_value=True)
     @patch.object(_win32_console, "SetConsoleTextAttribute")
     @patch.object(
         _win32_console, "GetConsoleScreenBufferInfo", return_value=StubScreenBufferInfo
     )
-    def test_write_styled_no_background_color(
-        _, SetConsoleTextAttribute, __, win32_handle
-    ):
+    def test_write_styled_no_background_color(_, SetConsoleTextAttribute, win32_handle):
         style = Style.parse("blue")
         text = "Hello, world!"
-        term = LegacyWindowsTerm()
+        term = LegacyWindowsTerm(sys.stdout)
 
         term.write_styled(text, style)
 
@@ -203,7 +190,7 @@ def test_write_styled_no_background_color(
     def test_erase_line(
         _, FillConsoleOutputAttribute, FillConsoleOutputCharacter, win32_handle
     ):
-        term = LegacyWindowsTerm()
+        term = LegacyWindowsTerm(sys.stdout)
         term.erase_line()
         start = WindowsCoordinates(row=CURSOR_Y, col=0)
         FillConsoleOutputCharacter.assert_called_once_with(
@@ -221,7 +208,7 @@ def test_erase_line(
     def test_erase_end_of_line(
         _, FillConsoleOutputAttribute, FillConsoleOutputCharacter, win32_handle
     ):
-        term = LegacyWindowsTerm()
+        term = LegacyWindowsTerm(sys.stdout)
         term.erase_end_of_line()
 
         FillConsoleOutputCharacter.assert_called_once_with(
@@ -242,7 +229,7 @@ def test_erase_end_of_line(
     def test_erase_start_of_line(
         _, FillConsoleOutputAttribute, FillConsoleOutputCharacter, win32_handle
     ):
-        term = LegacyWindowsTerm()
+        term = LegacyWindowsTerm(sys.stdout)
         term.erase_start_of_line()
 
         start = WindowsCoordinates(CURSOR_Y, 0)
@@ -260,7 +247,7 @@ def test_erase_start_of_line(
     )
     def test_move_cursor_to(_, SetConsoleCursorPosition, win32_handle):
         coords = WindowsCoordinates(row=4, col=5)
-        term = LegacyWindowsTerm()
+        term = LegacyWindowsTerm(sys.stdout)
 
         term.move_cursor_to(coords)
 
@@ -274,7 +261,7 @@ def test_move_cursor_to_out_of_bounds_row(
         _, SetConsoleCursorPosition, win32_handle
     ):
         coords = WindowsCoordinates(row=-1, col=4)
-        term = LegacyWindowsTerm()
+        term = LegacyWindowsTerm(sys.stdout)
 
         term.move_cursor_to(coords)
 
@@ -288,7 +275,7 @@ def test_move_cursor_to_out_of_bounds_col(
         _, SetConsoleCursorPosition, win32_handle
     ):
         coords = WindowsCoordinates(row=10, col=-4)
-        term = LegacyWindowsTerm()
+        term = LegacyWindowsTerm(sys.stdout)
 
         term.move_cursor_to(coords)
 
@@ -299,7 +286,7 @@ def test_move_cursor_to_out_of_bounds_col(
         _win32_console, "GetConsoleScreenBufferInfo", return_value=StubScreenBufferInfo
     )
     def test_move_cursor_up(_, SetConsoleCursorPosition, win32_handle):
-        term = LegacyWindowsTerm()
+        term = LegacyWindowsTerm(sys.stdout)
 
         term.move_cursor_up()
 
@@ -312,7 +299,7 @@ def test_move_cursor_up(_, SetConsoleCursorPosition, win32_handle):
         _win32_console, "GetConsoleScreenBufferInfo", return_value=StubScreenBufferInfo
     )
     def test_move_cursor_down(_, SetConsoleCursorPosition, win32_handle):
-        term = LegacyWindowsTerm()
+        term = LegacyWindowsTerm(sys.stdout)
 
         term.move_cursor_down()
 
@@ -325,7 +312,7 @@ def test_move_cursor_down(_, SetConsoleCursorPosition, win32_handle):
         _win32_console, "GetConsoleScreenBufferInfo", return_value=StubScreenBufferInfo
     )
     def test_move_cursor_forward(_, SetConsoleCursorPosition, win32_handle):
-        term = LegacyWindowsTerm()
+        term = LegacyWindowsTerm(sys.stdout)
 
         term.move_cursor_forward()
 
@@ -343,7 +330,7 @@ def test_move_cursor_forward_newline_wrap(SetConsoleCursorPosition, win32_handle
             "GetConsoleScreenBufferInfo",
             return_value=cursor_at_end_of_line,
         ):
-            term = LegacyWindowsTerm()
+            term = LegacyWindowsTerm(sys.stdout)
             term.move_cursor_forward()
 
         SetConsoleCursorPosition.assert_called_once_with(
@@ -355,7 +342,7 @@ def test_move_cursor_forward_newline_wrap(SetConsoleCursorPosition, win32_handle
         _win32_console, "GetConsoleScreenBufferInfo", return_value=StubScreenBufferInfo
     )
     def test_move_cursor_to_column(_, SetConsoleCursorPosition, win32_handle):
-        term = LegacyWindowsTerm()
+        term = LegacyWindowsTerm(sys.stdout)
         term.move_cursor_to_column(5)
         SetConsoleCursorPosition.assert_called_once_with(
             win32_handle, coords=WindowsCoordinates(CURSOR_Y, 5)
@@ -366,7 +353,7 @@ def test_move_cursor_to_column(_, SetConsoleCursorPosition, win32_handle):
         _win32_console, "GetConsoleScreenBufferInfo", return_value=StubScreenBufferInfo
     )
     def test_move_cursor_backward(_, SetConsoleCursorPosition, win32_handle):
-        term = LegacyWindowsTerm()
+        term = LegacyWindowsTerm(sys.stdout)
         term.move_cursor_backward()
         SetConsoleCursorPosition.assert_called_once_with(
             win32_handle, coords=WindowsCoordinates(row=CURSOR_Y, col=CURSOR_X - 1)
@@ -384,7 +371,7 @@ def test_move_cursor_backward_prev_line_wrap(
             "GetConsoleScreenBufferInfo",
             return_value=cursor_at_start_of_line,
         ):
-            term = LegacyWindowsTerm()
+            term = LegacyWindowsTerm(sys.stdout)
             term.move_cursor_backward()
         SetConsoleCursorPosition.assert_called_once_with(
             win32_handle,
@@ -396,7 +383,7 @@ def test_move_cursor_backward_prev_line_wrap(
         _win32_console, "GetConsoleScreenBufferInfo", return_value=StubScreenBufferInfo
     )
     def test_hide_cursor(_, SetConsoleCursorInfo, win32_handle):
-        term = LegacyWindowsTerm()
+        term = LegacyWindowsTerm(sys.stdout)
         term.hide_cursor()
 
         call_args = SetConsoleCursorInfo.call_args_list
@@ -412,7 +399,7 @@ def test_hide_cursor(_, SetConsoleCursorInfo, win32_handle):
         _win32_console, "GetConsoleScreenBufferInfo", return_value=StubScreenBufferInfo
     )
     def test_show_cursor(_, SetConsoleCursorInfo, win32_handle):
-        term = LegacyWindowsTerm()
+        term = LegacyWindowsTerm(sys.stdout)
         term.show_cursor()
 
         call_args = SetConsoleCursorInfo.call_args_list
@@ -425,14 +412,14 @@ def test_show_cursor(_, SetConsoleCursorInfo, win32_handle):
 
     @patch.object(_win32_console, "SetConsoleTitle", return_value=None)
     def test_set_title(SetConsoleTitle):
-        term = LegacyWindowsTerm()
+        term = LegacyWindowsTerm(sys.stdout)
         term.set_title("title")
 
         SetConsoleTitle.assert_called_once_with("title")
 
     @patch.object(_win32_console, "SetConsoleTitle", return_value=None)
     def test_set_title_too_long(_):
-        term = LegacyWindowsTerm()
+        term = LegacyWindowsTerm(sys.stdout)
 
         with pytest.raises(AssertionError):
             term.set_title("a" * 255)