Repository: Textualize/rich (python)

Issue:
[BUG] `AttributeError` using `Console.capture` when run with `pythonw`
**Describe the bug**

This code, when run using `pythonw`:

```py
table = Table(box=DOUBLE_EDGE)
printer = Console(force_terminal=False)
with printer.capture() as capture:
    printer.print(table)
```

fails with this stack trace:

```text
Traceback (most recent call last):
  File "<MY CODE>", line ...
    with printer.capture() as capture:
  File "rich\console.py", line 336, in __exit__
    self._result = self._console.end_capture()
  File "rich\console.py", line 852, in end_capture
    self._exit_buffer()
  File "rich\console.py", line 796, in _exit_buffer
    self._check_buffer()
  File "rich\console.py", line 1979, in _check_buffer
    self.file.fileno() in _STD_STREAMS_OUTPUT
AttributeError: 'NoneType' object has no attribute 'fileno'
```

My guess is that `pythonw` causes `Console` to get some attributes that are usually not null, but in this case, are.

I can probably immediately work around this by instantiating the `Console` like so:

```py
table = Table(box=DOUBLE_EDGE)
capture = StringIO()
printer = Console(file=capture)
printer.print(table)
```

but Rich could probably cater for the `Console` instance having no `STDOUT` in some cases (such as above).

**Platform**

* Windows 10
* Python v3.10.0

I've avoided including details generated by `python -m rich.diagnose` because more diagnostic information would be provided using `pythonw ...`—but of course, since it disables `STDOUT`, there's no obvious way to grab that info for you. 🤣

Pull request:
Supporting no stdout, fix force color bug
## Type of changes

- [x] Bug fix
- [ ] New feature
- [ ] Documentation / docstrings
- [ ] Tests
- [ ] Other

## Checklist

- [x] I've run the latest [black](https://github.com/psf/black) with default args on new code.
- [x] I've updated CHANGELOG.md and CONTRIBUTORS.md where appropriate.
- [x] I've added tests for new code.
- [x] I accept that @willmcgugan may be pedantic in the code review.

## Description

Fixes #2400.

Ensures RIch can run when stdout is not available (for example from pythonw).

I've tested on a Windows machine and ensured that you can use a console (and capture from it) as expected when using python.

Also fixes the issue with FORCE_COLOR.

<img width="851" alt="image" src="https://user-images.githubusercontent.com/5740731/189097955-d533b49e-f284-4c5a-a801-96b53a019841.png">

<img width="797" alt="image" src="https://user-images.githubusercontent.com/5740731/189098024-cc83c627-e2aa-43ac-97e0-ca902651ef18.png">



Patch:
diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml
index c21f7a64..624f5b88 100644
--- a/.github/workflows/codespell.yml
+++ b/.github/workflows/codespell.yml
@@ -7,4 +7,4 @@ jobs:
       - uses: actions/checkout@v3
       - run: python3 -m pip install codespell
       - run: codespell --ignore-words-list="ba,fo,hel,revered,womens"
-          --skip="./README.*.md,*.svg,./benchmarks/snippets.py"
+          --skip="./README.*.md,*.svg,./benchmarks/snippets.py,./tests,./tools"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3b723ded..4d355b82 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ### Fixed
 
+- Handle stdout/stderr being null https://github.com/Textualize/rich/pull/2513
 - Fix NO_COLOR support on legacy Windows https://github.com/Textualize/rich/pull/2458
 
 ## [12.5.2] - 2022-07-18
diff --git a/rich/_null_file.py b/rich/_null_file.py
new file mode 100644
index 00000000..49038bfc
--- /dev/null
+++ b/rich/_null_file.py
@@ -0,0 +1,83 @@
+from types import TracebackType
+from typing import IO, Iterable, Iterator, List, Optional, Type
+
+
+class NullFile(IO[str]):
+
+    # TODO: "mode", "name" and "closed" are only required for Python 3.6.
+
+    @property
+    def mode(self) -> str:
+        return ""
+
+    @property
+    def name(self) -> str:
+        return "NullFile"
+
+    def closed(self) -> bool:
+        return False
+
+    def close(self) -> None:
+        pass
+
+    def isatty(self) -> bool:
+        return False
+
+    def read(self, __n: int = 1) -> str:
+        return ""
+
+    def readable(self) -> bool:
+        return False
+
+    def readline(self, __limit: int = 1) -> str:
+        return ""
+
+    def readlines(self, __hint: int = 1) -> List[str]:
+        return []
+
+    def seek(self, __offset: int, __whence: int = 1) -> int:
+        return 0
+
+    def seekable(self) -> bool:
+        return False
+
+    def tell(self) -> int:
+        return 0
+
+    def truncate(self, __size: Optional[int] = 1) -> int:
+        return 0
+
+    def writable(self) -> bool:
+        return False
+
+    def writelines(self, __lines: Iterable[str]) -> None:
+        pass
+
+    def __next__(self) -> str:
+        return ""
+
+    def __iter__(self) -> Iterator[str]:
+        return iter([""])
+
+    def __enter__(self) -> IO[str]:
+        pass
+
+    def __exit__(
+        self,
+        __t: Optional[Type[BaseException]],
+        __value: Optional[BaseException],
+        __traceback: Optional[TracebackType],
+    ) -> None:
+        pass
+
+    def write(self, text: str) -> int:
+        return 0
+
+    def flush(self) -> None:
+        pass
+
+    def fileno(self) -> int:
+        return -1
+
+
+NULL_FILE = NullFile()
diff --git a/rich/color.py b/rich/color.py
index 9031ae37..ef2e895d 100644
--- a/rich/color.py
+++ b/rich/color.py
@@ -313,7 +313,7 @@ class Color(NamedTuple):
     """A triplet of color components, if an RGB color."""
 
     def __rich__(self) -> "Text":
-        """Dispays the actual color if Rich printed."""
+        """Displays the actual color if Rich printed."""
         from .style import Style
         from .text import Text
 
diff --git a/rich/console.py b/rich/console.py
index 4a3ebb55..585221e0 100644
--- a/rich/console.py
+++ b/rich/console.py
@@ -34,6 +34,8 @@ from typing import (
     cast,
 )
 
+from rich._null_file import NULL_FILE
+
 if sys.version_info >= (3, 8):
     from typing import Literal, Protocol, runtime_checkable
 else:
@@ -698,10 +700,14 @@ class Console:
 
         self._color_system: Optional[ColorSystem]
 
+        self._force_terminal = None
         if force_terminal is not None:
             self._force_terminal = force_terminal
         else:
-            self._force_terminal = self._environ.get("FORCE_COLOR") is not None
+            # If FORCE_COLOR env var has any value at all, we force terminal.
+            force_color = self._environ.get("FORCE_COLOR")
+            if force_color is not None:
+                self._force_terminal = True
 
         self._file = file
         self.quiet = quiet
@@ -751,6 +757,8 @@ class Console:
         """Get the file object to write to."""
         file = self._file or (sys.stderr if self.stderr else sys.stdout)
         file = getattr(file, "rich_proxied_file", file)
+        if file is None:
+            file = NULL_FILE
         return file
 
     @file.setter
diff --git a/rich/logging.py b/rich/logging.py
index ff93531a..96859934 100644
--- a/rich/logging.py
+++ b/rich/logging.py
@@ -3,10 +3,12 @@ from datetime import datetime
 from logging import Handler, LogRecord
 from pathlib import Path
 from types import ModuleType
-from typing import ClassVar, List, Optional, Iterable, Type, Union
+from typing import ClassVar, Iterable, List, Optional, Type, Union
+
+from rich._null_file import NullFile
 
 from . import get_console
-from ._log_render import LogRender, FormatTimeCallable
+from ._log_render import FormatTimeCallable, LogRender
 from .console import Console, ConsoleRenderable
 from .highlighter import Highlighter, ReprHighlighter
 from .text import Text
@@ -158,10 +160,16 @@ class RichHandler(Handler):
         log_renderable = self.render(
             record=record, traceback=traceback, message_renderable=message_renderable
         )
-        try:
-            self.console.print(log_renderable)
-        except Exception:
+        if isinstance(self.console.file, NullFile):
+            # Handles pythonw, where stdout/stderr are null, and we return NullFile
+            # instance from Console.file. In this case, we still want to make a log record
+            # even though we won't be writing anything to a file.
             self.handleError(record)
+        else:
+            try:
+                self.console.print(log_renderable)
+            except Exception:
+                self.handleError(record)
 
     def render_message(self, record: LogRecord, message: str) -> "ConsoleRenderable":
         """Render message text in to Text.


Test patch:
diff --git a/tests/test_cells.py b/tests/test_cells.py
index d64317f3..a780214a 100644
--- a/tests/test_cells.py
+++ b/tests/test_cells.py
@@ -1,6 +1,11 @@
 from rich import cells
 
 
+def test_cell_len_long_string():
+    # Long strings don't use cached cell length implementation
+    assert cells.cell_len("abc" * 200) == 3 * 200
+
+
 def test_set_cell_size():
     assert cells.set_cell_size("foo", 0) == ""
     assert cells.set_cell_size("f", 0) == ""
diff --git a/tests/test_console.py b/tests/test_console.py
index 07692f19..fa676535 100644
--- a/tests/test_console.py
+++ b/tests/test_console.py
@@ -9,6 +9,7 @@ from unittest import mock
 import pytest
 
 from rich import errors
+from rich._null_file import NullFile
 from rich.color import ColorSystem
 from rich.console import (
     CaptureError,
@@ -238,6 +239,15 @@ def test_print_json_indent_none():
     assert result == expected
 
 
+def test_console_null_file(monkeypatch):
+    # When stdout and stderr are null, Console.file should be replaced with NullFile
+    monkeypatch.setattr("sys.stdout", None)
+    monkeypatch.setattr("sys.stderr", None)
+
+    console = Console()
+    assert isinstance(console.file, NullFile)
+
+
 def test_log():
     console = Console(
         file=io.StringIO(),
@@ -859,6 +869,7 @@ def test_detect_color_system():
 
 def test_reset_height():
     """Test height is reset when rendering complex renderables."""
+
     # https://github.com/Textualize/rich/issues/2042
     class Panels:
         def __rich_console__(self, console, options):
@@ -897,9 +908,31 @@ def test_render_lines_height_minus_vertical_pad_is_negative():
     console.render_lines(Padding("hello", pad=(1, 0)), options=options)
 
 
-@mock.patch.dict(os.environ, {"FORCE_COLOR": "anything"})
-def test_force_color():
+def test_recording_no_stdout_and_no_stderr_files(monkeypatch):
+    # Rich should work even if there's no file available to write to.
+    # For example, pythonw nullifies output streams.
+    # Built-in print silently no-ops in pythonw.
+    # Related: https://github.com/Textualize/rich/issues/2400
+    monkeypatch.setattr("sys.stdout", None)
+    monkeypatch.setattr("sys.stderr", None)
+    console = Console(record=True)
+    console.print("hello world")
+    text = console.export_text()
+    assert text == "hello world\n"
+
+
+def test_capturing_no_stdout_and_no_stderr_files(monkeypatch):
+    monkeypatch.setattr("sys.stdout", None)
+    monkeypatch.setattr("sys.stderr", None)
+    console = Console()
+    with console.capture() as capture:
+        console.print("hello world")
+    assert capture.get() == "hello world\n"
+
+
+@pytest.mark.parametrize("env_value", ["", "something", "0"])
+def test_force_color(env_value):
     # Even though we use a non-tty file, the presence of FORCE_COLOR env var
     # means is_terminal returns True.
-    console = Console(file=io.StringIO())
+    console = Console(file=io.StringIO(), _environ={"FORCE_COLOR": env_value})
     assert console.is_terminal
diff --git a/tests/test_null_file.py b/tests/test_null_file.py
new file mode 100644
index 00000000..5f11e4f9
--- /dev/null
+++ b/tests/test_null_file.py
@@ -0,0 +1,26 @@
+from rich._null_file import NullFile
+
+
+def test_null_file():
+    file = NullFile()
+    with file:
+        assert file.write("abc") == 0
+        assert file.mode == ""
+        assert file.name == "NullFile"
+        assert not file.closed()
+        assert file.close() is None
+        assert not file.isatty()
+        assert file.read() == ""
+        assert not file.readable()
+        assert file.readline() == ""
+        assert file.readlines() == []
+        assert file.seek(0, 0) == 0
+        assert not file.seekable()
+        assert file.tell() == 0
+        assert file.truncate() == 0
+        assert file.writable() == False
+        assert file.writelines([""]) is None
+        assert next(file) == ""
+        assert next(iter(file)) == ""
+        assert file.fileno() == -1
+        assert file.flush() is None
diff --git a/tests/test_pretty.py b/tests/test_pretty.py
index f57066d0..33f62113 100644
--- a/tests/test_pretty.py
+++ b/tests/test_pretty.py
@@ -56,7 +56,7 @@ def test_install_max_depth():
     dh = sys.displayhook
     install(console, max_depth=1)
     sys.displayhook({"foo": {"bar": True}})
-    assert console.file.getvalue() == "{'foo': ...}\n"
+    assert console.file.getvalue() == "{'foo': {...}}\n"
     assert sys.displayhook is not dh