Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Supporting no stdout, fix force color bug #2513

Merged
merged 17 commits into from Sep 8, 2022
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/codespell.yml
Expand Up @@ -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"
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -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
Expand Down
83 changes: 83 additions & 0 deletions 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()
2 changes: 1 addition & 1 deletion rich/color.py
Expand Up @@ -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

Expand Down
10 changes: 9 additions & 1 deletion rich/console.py
Expand Up @@ -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:
Expand Down Expand Up @@ -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 = bool(force_color)
willmcgugan marked this conversation as resolved.
Show resolved Hide resolved

self._file = file
self.quiet = quiet
Expand Down Expand Up @@ -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
Expand Down
18 changes: 13 additions & 5 deletions rich/logging.py
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions 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) == ""
Expand Down
33 changes: 33 additions & 0 deletions tests/test_console.py
Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -897,6 +908,28 @@ def test_render_lines_height_minus_vertical_pad_is_negative():
console.render_lines(Padding("hello", pad=(1, 0)), options=options)


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"


@mock.patch.dict(os.environ, {"FORCE_COLOR": "anything"})
def test_force_color():
# Even though we use a non-tty file, the presence of FORCE_COLOR env var
Expand Down
26 changes: 26 additions & 0 deletions 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
2 changes: 1 addition & 1 deletion tests/test_pretty.py
Expand Up @@ -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


Expand Down