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.
Did I solve your problem? 

Why not buy the devs a [coffee](https://ko-fi.com/textualize) to say thanks?
OK with https://github.com/Textualize/rich/commit/51121fe30273d4b01e0801a7de4a05bd2b9031f6 installed bug.py is working correctly, but there's still three more situations which are crashing or working strangely :slightly_frowning_face: 

### `bug_reprint.py` -- calling console.print again crashes with an OSError

```python
import subprocess
import sys

code = """\
from rich.console import Console

console = Console(stderr=False)
console.print("spam")
console.print("eggs")
"""
err_code = code.replace("stderr=False", "stderr=True")
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")
```

```pwsh
(venv) PS R:\Programming\diff-shades> python .\bug_reprint.py
[capture_output=True via sys.stdout]
proc.stdout='spam\n' proc.stderr='Traceback (most recent call last):\n  File "<stdin>", line 5, in <module>\n  File "R:\\Programming\\diff-shades\\venv\\lib\\site-packages\\rich\\console.py", line 1642, in print\n    self._buffer.extend(new_segments)\n  File "R:\\Programming\\diff-shades\\venv\\lib\\site-packages\\rich\\console.py", line 841, in __exit__\n    self._exit_buffer()\n  File "R:\\Programming\\diff-shades\\venv\\lib\\site-packages\\rich\\console.py", line 799, in _exit_buffer\n    self._check_buffer()\n  File "R:\\Programming\\diff-shades\\venv\\lib\\site-packages\\rich\\console.py", line 1935, in _check_buffer\n    with open(file_no, "w") as output_file:\nOSError: [WinError 6] The handle is invalid\n'
[capture_output=True via sys.stderr]
proc.stdout='' proc.stderr='spam\neggs\n'
[capture_output=False via sys.stdout]
spam
eggs
```

<details>
<summary>with rich==11.2.0</summary>

```pwsh
(venv) PS R:\Programming\diff-shades> python .\bug_reprint.py
[capture_output=True via sys.stdout]
proc.stdout='spam\neggs\n' proc.stderr=''
[capture_output=True via sys.stderr]
proc.stdout='' proc.stderr='spam\neggs\n'
[capture_output=False via sys.stdout]
spam
eggs
```

</details>

### `bug_reprint_builtin.py` -- calling the builtin print after also crashes with an OSError

```python3
import subprocess
import sys

code = """\
from rich.console import Console

console = Console(stderr=False)
console.print("spam")
print("eggs")
"""
err_code = code.replace("stderr=False", "stderr=True")
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")
```

```pwsh
(venv) PS R:\Programming\diff-shades> python .\bug_reprint_builtin.py
[capture_output=True via sys.stdout]
proc.stdout='spam\n' proc.stderr="Exception ignored in: <_io.TextIOWrapper name='<stdout>' mode='w' encoding='cp1252'>\nOSError: [Errno 9] Bad file descriptor\n"
[capture_output=True via sys.stderr]
proc.stdout='eggs\n' proc.stderr='spam\n'
[capture_output=False via sys.stdout]
spam
eggs
(venv) PS R:\Programming\diff-shades>
```

<details>
<summary>with rich==11.2.0</summary>

```pwsh
[capture_output=True via sys.stdout]
proc.stdout='spam\neggs\n' proc.stderr=''
[capture_output=True via sys.stderr]
proc.stdout='eggs\n' proc.stderr='spam\n'
[capture_output=False via sys.stdout]
spam
eggs
```

</details>

### `bug_record.py` -- and recording the console output still doesn't work

```python3
import os
import subprocess
import sys

code = """\
from rich.console import Console

console = Console(stderr=False, record=True)
console.print("spam")
with open("log.txt", "w", encoding="utf-8") as f:
    f.write(console.export_text())
"""
err_code = code.replace("stderr=False", "stderr=True")
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=}")
with open("log.txt", encoding="utf-8") as f:
    print(f"recorded: {f.read()}")

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=}")
with open("log.txt", encoding="utf-8") as f:
    print(f"recorded: {f.read()}")

print("[capture_output=False via sys.stdout]")
proc = subprocess.run(cmd, input=code, capture_output=False, encoding="utf-8")
with open("log.txt", encoding="utf-8") as f:
    print(f"recorded: {f.read()}")

os.unlink("log.txt")
```

```pwsh
(venv) PS R:\Programming\diff-shades> python .\bug_record.py
[capture_output=True via sys.stdout]
proc.stdout='spam\n' proc.stderr=''
recorded:
[capture_output=True via sys.stderr]
proc.stdout='' proc.stderr='spam\n'
recorded: spam

[capture_output=False via sys.stdout]
spam
recorded: spam
```

<details>
<summary>with rich==11.2.0</summary>

```pwsh
(venv) PS R:\Programming\diff-shades> python .\bug_record.py
[capture_output=True via sys.stdout]
proc.stdout='spam\n' proc.stderr=''
recorded: spam

[capture_output=True via sys.stderr]
proc.stdout='' proc.stderr='spam\n'
recorded: spam

[capture_output=False via sys.stdout]
spam
recorded: spam
```

</details>


@ichard26 Thanks for investigating in such detail, and for the examples. It really helps! I'll take a look into these issues shortly 👍

Resolving patch:
diff --git a/rich/_win32_console.py b/rich/_win32_console.py
--- a/rich/_win32_console.py
+++ b/rich/_win32_console.py
@@ -4,7 +4,7 @@
 """
 import ctypes
 import sys
-from typing import Any, NamedTuple, Type, cast
+from typing import Any
 
 windll: Any = None
 if sys.platform == "win32":
@@ -14,6 +14,7 @@
 
 import time
 from ctypes import Structure, byref, wintypes
+from typing import IO, NamedTuple, Type, cast
 
 from rich.color import ColorSystem
 from rich.style import Style
@@ -333,7 +334,7 @@ class LegacyWindowsTerm:
         15,  # bright white
     ]
 
-    def __init__(self, file: IO[str]) -> None:
+    def __init__(self, file: "IO[str]") -> None:
         handle = GetStdHandle(STDOUT)
         self._handle = handle
         default_text = GetConsoleScreenBufferInfo(handle).wAttributes
diff --git a/rich/console.py b/rich/console.py
--- a/rich/console.py
+++ b/rich/console.py
@@ -82,6 +82,15 @@ class NoChange:
 
 NO_CHANGE = NoChange()
 
+try:
+    _STDOUT_FILENO = sys.__stdout__.fileno()
+except Exception:
+    _STDOUT_FILENO = 1
+
+try:
+    _STDERR_FILENO = sys.__stderr__.fileno()
+except Exception:
+    _STDERR_FILENO = 2
 
 CONSOLE_HTML_FORMAT = """\
 <!DOCTYPE html>
@@ -1909,12 +1918,21 @@ def log(
                 buffer_extend(line)
 
     def _check_buffer(self) -> None:
-        """Check if the buffer may be rendered."""
+        """Check if the buffer may be rendered. Render it if it can (e.g. Console.quiet is False)
+        Rendering is supported on Windows, Unix and Jupyter environments. For
+        legacy Windows consoles, the win32 API is called directly.
+        This method will also record what it renders if recording is enabled via Console.record.
+        """
         if self.quiet:
             del self._buffer[:]
             return
         with self._lock:
             if self._buffer_index == 0:
+
+                if self.record:
+                    with self._record_buffer_lock:
+                        self._record_buffer.extend(self._buffer[:])
+
                 if self.is_jupyter:  # pragma: no cover
                     from .jupyter import display
 
@@ -1927,18 +1945,19 @@ def _check_buffer(self) -> None:
                         except (ValueError, io.UnsupportedOperation):
                             file_no = -1
 
-                        legacy_windows_stdout = self.legacy_windows and file_no == 1
-                        if legacy_windows_stdout:
+                        stdout_num = _STDOUT_FILENO
+                        stderr_num = _STDERR_FILENO
+                        is_std_stream = file_no in (stdout_num, stderr_num)
+                        legacy_windows_std = self.legacy_windows and is_std_stream
+                        if legacy_windows_std:
                             from rich._win32_console import LegacyWindowsTerm
                             from rich._windows_renderer import legacy_windows_render
 
-                            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:
+                            legacy_windows_render(
+                                self._buffer[:], LegacyWindowsTerm(self.file)
+                            )
+                        else:
+                            # Either a non-std stream on legacy Windows, or modern Windows.
                             text = self._render_buffer(self._buffer[:])
                             # https://bugs.python.org/issue37871
                             write = self.file.write
@@ -1965,9 +1984,6 @@ def _render_buffer(self, buffer: Iterable[Segment]) -> str:
         append = output.append
         color_system = self._color_system
         legacy_windows = self.legacy_windows
-        if self.record:
-            with self._record_buffer_lock:
-                self._record_buffer.extend(buffer)
         not_terminal = not self.is_terminal
         if self.no_color and color_system:
             buffer = Segment.remove_color(buffer)


Test patch: