diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml index c21f7a642..624f5b888 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 3b723ded1..4d355b82b 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 000000000..49038bfcb --- /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 9031ae378..ef2e895d7 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 4a3ebb559..585221e06 100644 --- a/rich/console.py +++ b/rich/console.py @@ -34,6 +34,8 @@ 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 @@ def __init__( 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 @@ def file(self) -> IO[str]: """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 ff93531a7..96859934e 100644 --- a/rich/logging.py +++ b/rich/logging.py @@ -3,10 +3,12 @@ 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 @@ def emit(self, record: LogRecord) -> None: 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. diff --git a/tests/test_cells.py b/tests/test_cells.py index d64317f3b..a780214a8 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 07692f198..fa6765357 100644 --- a/tests/test_console.py +++ b/tests/test_console.py @@ -9,6 +9,7 @@ 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 000000000..5f11e4f91 --- /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 f57066d04..33f621134 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