diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 37db46430a..b9425011c2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,7 +20,6 @@ repos: - repo: https://github.com/pre-commit/pygrep-hooks rev: v1.10.0 hooks: - - id: python-check-mock-methods - id: python-no-log-warn - id: python-use-type-annotations - id: rst-directive-colons diff --git a/CHANGELOG.md b/CHANGELOG.md index 27a6fc01e9..ab00e7fb8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased ### Added +- Added env var `TTY_COMPATIBLE` to override auto-detection of TTY support (See console.rst for details). https://github.com/Textualize/rich/pull/3675 +### Changed + +- An empty `NO_COLOR` env var is now considered disabled. https://github.com/Textualize/rich/pull/3675 +- An empty `FORCE_COLOR` env var is now considered disabled. https://github.com/Textualize/rich/pull/3675 - Rich tracebacks will now render notes on Python 3.11 onwards (added with `Exception.add_note`) https://github.com/Textualize/rich/pull/3676 + ## [13.9.4] - 2024-11-01 ### Changed diff --git a/docs/source/console.rst b/docs/source/console.rst index 5c0aeb329e..c9c35c06de 100644 --- a/docs/source/console.rst +++ b/docs/source/console.rst @@ -420,11 +420,18 @@ Rich respects some standard environment variables. Setting the environment variable ``TERM`` to ``"dumb"`` or ``"unknown"`` will disable color/style and some features that require moving the cursor, such as progress bars. -If the environment variable ``FORCE_COLOR`` is set, then color/styles will be enabled regardless of the value of ``TERM``. This is useful on CI systems which aren't terminals but can none-the-less display ANSI escape sequences. +If the environment variable ``FORCE_COLOR`` is set and non-empty, then color/styles will be enabled regardless of the value of ``TERM``. -If the environment variable ``NO_COLOR`` is set, Rich will disable all color in the output. This takes precedence over ``FORCE_COLOR``. See `no_color `_ for details. +If the environment variable ``NO_COLOR`` is set, Rich will disable all color in the output. ``NO_COLOR`` takes precedence over ``FORCE_COLOR``. See `no_color `_ for details. .. note:: The ``NO_COLOR`` environment variable removes *color* only. Styles such as dim, bold, italic, underline etc. are preserved. -If ``width`` / ``height`` arguments are not explicitly provided as arguments to ``Console`` then the environment variables ``COLUMNS``/``LINES`` can be used to set the console width/height. ``JUPYTER_COLUMNS``/``JUPYTER_LINES`` behave similarly and are used in Jupyter. +The environment variable ``TTY_COMPATIBLE`` is used to override Rich's auto-detection of terminal support. If ``TTY_COMPATIBLE`` is set to ``1`` then Rich will assume it is writing to a device which can handle escape sequences like a terminal. If ``TTY_COMPATIBLE`` is set to ``"0"``, then Rich will assume that it is not writing to a terminal. If the variable is not set, or set to a value other than "0" or "1", then Rich will attempt to auto-detect terminal support. + +.. note:: + If you want Rich output in CI or Github Actions, then you should set ``TTY_COMPATIBLE=1``. + +Note that these variable set the default behavior. If you explicitly set ``force_terminal`` in the Console constructor, then this will take precedence over the environment variables. + +If ``width`` / ``height`` arguments are not explicitly provided as arguments to ``Console`` then the environment variables ``COLUMNS`` / ``LINES`` can be used to set the console width / height. ``JUPYTER_COLUMNS`` / ``JUPYTER_LINES`` behave similarly and are used in Jupyter. diff --git a/rich/console.py b/rich/console.py index 3ec9a8aabb..6725e405c3 100644 --- a/rich/console.py +++ b/rich/console.py @@ -500,7 +500,7 @@ def group(fit: bool = True) -> Callable[..., Callable[..., Group]]: """ def decorator( - method: Callable[..., Iterable[RenderableType]] + method: Callable[..., Iterable[RenderableType]], ) -> Callable[..., Group]: """Convert a method that returns an iterable of renderables in to a Group.""" @@ -735,7 +735,9 @@ def __init__( self.get_time = get_time or monotonic self.style = style self.no_color = ( - no_color if no_color is not None else "NO_COLOR" in self._environ + no_color + if no_color is not None + else self._environ.get("NO_COLOR", "") != "" ) self.is_interactive = ( (self.is_terminal and not self.is_dumb_terminal) @@ -933,11 +935,13 @@ def is_terminal(self) -> bool: Returns: bool: True if the console writing to a device capable of - understanding terminal codes, otherwise False. + understanding escape sequences, otherwise False. """ + # If dev has explicitly set this value, return it if self._force_terminal is not None: return self._force_terminal + # Fudge for Idle if hasattr(sys.stdin, "__module__") and sys.stdin.__module__.startswith( "idlelib" ): @@ -948,12 +952,22 @@ def is_terminal(self) -> bool: # return False for Jupyter, which may have FORCE_COLOR set return False - # If FORCE_COLOR env var has any value at all, we assume a terminal. - force_color = self._environ.get("FORCE_COLOR") - if force_color is not None: - self._force_terminal = True + environ = self._environ + + tty_compatible = environ.get("TTY_COMPATIBLE", "") + # 0 indicates device is not tty compatible + if tty_compatible == "0": + return False + # 1 indicates device is tty compatible + if tty_compatible == "1": return True + # https://force-color.org/ + force_color = environ.get("FORCE_COLOR") + if force_color is not None: + return force_color != "" + + # Any other value defaults to auto detect isatty: Optional[Callable[[], bool]] = getattr(self.file, "isatty", None) try: return False if isatty is None else isatty() diff --git a/rich/diagnose.py b/rich/diagnose.py index 91e55bd8af..5d163877da 100644 --- a/rich/diagnose.py +++ b/rich/diagnose.py @@ -15,16 +15,17 @@ def report() -> None: # pragma: no cover inspect(features) env_names = ( - "TERM", - "COLORTERM", "CLICOLOR", - "NO_COLOR", - "TERM_PROGRAM", + "COLORTERM", "COLUMNS", - "LINES", + "JPY_PARENT_PID", "JUPYTER_COLUMNS", "JUPYTER_LINES", - "JPY_PARENT_PID", + "LINES", + "NO_COLOR", + "TERM_PROGRAM", + "TERM", + "TTY_COMPATIBLE", "VSCODE_VERBOSE_LOGGING", ) env = {name: os.getenv(name) for name in env_names} diff --git a/tests/test_console.py b/tests/test_console.py index 407a138ecd..206dbdd143 100644 --- a/tests/test_console.py +++ b/tests/test_console.py @@ -34,7 +34,7 @@ os.get_terminal_size -def test_dumb_terminal(): +def test_dumb_terminal() -> None: console = Console(force_terminal=True, _environ={}) assert console.color_system is not None @@ -45,14 +45,14 @@ def test_dumb_terminal(): assert height == 25 -def test_soft_wrap(): +def test_soft_wrap() -> None: console = Console(file=io.StringIO(), width=20, soft_wrap=True) console.print("foo " * 10) assert console.file.getvalue() == "foo " * 20 @pytest.mark.skipif(sys.platform == "win32", reason="does not run on windows") -def test_16color_terminal(): +def test_16color_terminal() -> None: console = Console( force_terminal=True, _environ={"TERM": "xterm-16color"}, legacy_windows=False ) @@ -60,7 +60,7 @@ def test_16color_terminal(): @pytest.mark.skipif(sys.platform == "win32", reason="does not run on windows") -def test_truecolor_terminal(): +def test_truecolor_terminal() -> None: console = Console( force_terminal=True, legacy_windows=False, @@ -70,7 +70,7 @@ def test_truecolor_terminal(): @pytest.mark.skipif(sys.platform == "win32", reason="does not run on windows") -def test_kitty_terminal(): +def test_kitty_terminal() -> None: console = Console( force_terminal=True, legacy_windows=False, @@ -79,7 +79,7 @@ def test_kitty_terminal(): assert console.color_system == "256" -def test_console_options_update(): +def test_console_options_update() -> None: options = ConsoleOptions( ConsoleDimensions(80, 25), max_height=25, @@ -103,7 +103,7 @@ def test_console_options_update(): assert options_copy == options and options_copy is not options -def test_console_options_update_height(): +def test_console_options_update_height() -> None: options = ConsoleOptions( ConsoleDimensions(80, 25), max_height=25, @@ -120,7 +120,7 @@ def test_console_options_update_height(): assert render_options.max_height == 12 -def test_init(): +def test_init() -> None: console = Console(color_system=None) assert console._color_system == None console = Console(color_system="standard") @@ -128,7 +128,7 @@ def test_init(): console = Console(color_system="auto") -def test_size(): +def test_size() -> None: console = Console() w, h = console.size assert console.width == w @@ -180,37 +180,37 @@ def get_terminal_size_mock_impl(fileno: int = None) -> Tuple[int, int]: assert (w, h) == expected_size -def test_repr(): +def test_repr() -> None: console = Console() assert isinstance(repr(console), str) assert isinstance(str(console), str) -def test_print(): +def test_print() -> None: console = Console(file=io.StringIO(), color_system="truecolor") console.print("foo") assert console.file.getvalue() == "foo\n" -def test_print_multiple(): +def test_print_multiple() -> None: console = Console(file=io.StringIO(), color_system="truecolor") console.print("foo", "bar") assert console.file.getvalue() == "foo bar\n" -def test_print_text(): +def test_print_text() -> None: console = Console(file=io.StringIO(), color_system="truecolor") console.print(Text("foo", style="bold")) - assert console.file.getvalue() == "\x1B[1mfoo\x1B[0m\n" + assert console.file.getvalue() == "\x1b[1mfoo\x1b[0m\n" -def test_print_text_multiple(): +def test_print_text_multiple() -> None: console = Console(file=io.StringIO(), color_system="truecolor") console.print(Text("foo", style="bold"), Text("bar"), "baz") - assert console.file.getvalue() == "\x1B[1mfoo\x1B[0m bar baz\n" + assert console.file.getvalue() == "\x1b[1mfoo\x1b[0m bar baz\n" -def test_print_json(): +def test_print_json() -> None: console = Console(file=io.StringIO(), color_system="truecolor") console.print_json('[false, true, null, "foo"]', indent=4) result = console.file.getvalue() @@ -219,13 +219,13 @@ def test_print_json(): assert result == expected -def test_print_json_error(): +def test_print_json_error() -> None: console = Console(file=io.StringIO(), color_system="truecolor") with pytest.raises(TypeError): console.print_json(["foo"], indent=4) -def test_print_json_data(): +def test_print_json_data() -> None: console = Console(file=io.StringIO(), color_system="truecolor") console.print_json(data=[False, True, None, "foo"], indent=4) result = console.file.getvalue() @@ -234,7 +234,7 @@ def test_print_json_data(): assert result == expected -def test_print_json_ensure_ascii(): +def test_print_json_ensure_ascii() -> None: console = Console(file=io.StringIO(), color_system="truecolor") console.print_json(data={"foo": "💩"}, ensure_ascii=False) result = console.file.getvalue() @@ -243,7 +243,7 @@ def test_print_json_ensure_ascii(): assert result == expected -def test_print_json_with_default_ensure_ascii(): +def test_print_json_with_default_ensure_ascii() -> None: console = Console(file=io.StringIO(), color_system="truecolor") console.print_json(data={"foo": "💩"}) result = console.file.getvalue() @@ -252,7 +252,7 @@ def test_print_json_with_default_ensure_ascii(): assert result == expected -def test_print_json_indent_none(): +def test_print_json_indent_none() -> None: console = Console(file=io.StringIO(), color_system="truecolor") data = {"name": "apple", "count": 1} console.print_json(data=data, indent=None) @@ -261,7 +261,7 @@ def test_print_json_indent_none(): assert result == expected -def test_console_null_file(monkeypatch): +def test_console_null_file(monkeypatch) -> None: # When stdout and stderr are null, Console.file should be replaced with NullFile monkeypatch.setattr("sys.stdout", None) monkeypatch.setattr("sys.stderr", None) @@ -270,7 +270,7 @@ def test_console_null_file(monkeypatch): assert isinstance(console.file, NullFile) -def test_log(): +def test_log() -> None: console = Console( file=io.StringIO(), width=80, @@ -286,7 +286,7 @@ def test_log(): assert result == expected -def test_log_milliseconds(): +def test_log_milliseconds() -> None: def time_formatter(timestamp: datetime) -> Text: return Text("TIME") @@ -298,13 +298,13 @@ def time_formatter(timestamp: datetime) -> Text: assert result == "TIME foo \n" -def test_print_empty(): +def test_print_empty() -> None: console = Console(file=io.StringIO(), color_system="truecolor") console.print() assert console.file.getvalue() == "\n" -def test_markup_highlight(): +def test_markup_highlight() -> None: console = Console(file=io.StringIO(), color_system="truecolor") console.print("'[bold]foo[/bold]'") assert ( @@ -313,13 +313,13 @@ def test_markup_highlight(): ) -def test_print_style(): +def test_print_style() -> None: console = Console(file=io.StringIO(), color_system="truecolor") console.print("foo", style="bold") assert console.file.getvalue() == "\x1b[1mfoo\x1b[0m\n" -def test_show_cursor(): +def test_show_cursor() -> None: console = Console( file=io.StringIO(), force_terminal=True, legacy_windows=False, _environ={} ) @@ -329,31 +329,31 @@ def test_show_cursor(): assert console.file.getvalue() == "\x1b[?25lfoo\n\x1b[?25h" -def test_clear(): +def test_clear() -> None: console = Console(file=io.StringIO(), force_terminal=True, _environ={}) console.clear() console.clear(home=False) assert console.file.getvalue() == "\033[2J\033[H" + "\033[2J" -def test_clear_no_terminal(): +def test_clear_no_terminal() -> None: console = Console(file=io.StringIO()) console.clear() console.clear(home=False) assert console.file.getvalue() == "" -def test_get_style(): +def test_get_style() -> None: console = Console() console.get_style("repr.brace") == Style(bold=True) -def test_get_style_default(): +def test_get_style_default() -> None: console = Console() console.get_style("foobar", default="red") == Style(color="red") -def test_get_style_error(): +def test_get_style_error() -> None: console = Console() with pytest.raises(errors.MissingStyle): console.get_style("nosuchstyle") @@ -361,20 +361,20 @@ def test_get_style_error(): console.get_style("foo bar") -def test_render_error(): +def test_render_error() -> None: console = Console() with pytest.raises(errors.NotRenderableError): list(console.render([], console.options)) -def test_control(): +def test_control() -> None: console = Console(file=io.StringIO(), force_terminal=True, _environ={}) console.control(Control.clear()) console.print("BAR") assert console.file.getvalue() == "\x1b[2JBAR\n" -def test_capture(): +def test_capture() -> None: console = Console() with console.capture() as capture: with pytest.raises(CaptureError): @@ -383,7 +383,7 @@ def test_capture(): assert capture.get() == "Hello\n" -def test_input(monkeypatch, capsys): +def test_input(monkeypatch, capsys) -> None: def fake_input(prompt=""): console.file.write(prompt) return "bar" @@ -395,7 +395,7 @@ def fake_input(prompt=""): assert user_input == "bar" -def test_input_password(monkeypatch, capsys): +def test_input_password(monkeypatch, capsys) -> None: def fake_input(prompt, stream=None): console.file.write(prompt) return "bar" @@ -409,37 +409,37 @@ def fake_input(prompt, stream=None): assert user_input == "bar" -def test_status(): +def test_status() -> None: console = Console(file=io.StringIO(), force_terminal=True, width=20) status = console.status("foo") assert isinstance(status, Status) -def test_justify_none(): +def test_justify_none() -> None: console = Console(file=io.StringIO(), force_terminal=True, width=20) console.print("FOO", justify=None) assert console.file.getvalue() == "FOO\n" -def test_justify_left(): +def test_justify_left() -> None: console = Console(file=io.StringIO(), force_terminal=True, width=20, _environ={}) console.print("FOO", justify="left") assert console.file.getvalue() == "FOO \n" -def test_justify_center(): +def test_justify_center() -> None: console = Console(file=io.StringIO(), force_terminal=True, width=20, _environ={}) console.print("FOO", justify="center") assert console.file.getvalue() == " FOO \n" -def test_justify_right(): +def test_justify_right() -> None: console = Console(file=io.StringIO(), force_terminal=True, width=20, _environ={}) console.print("FOO", justify="right") assert console.file.getvalue() == " FOO\n" -def test_justify_renderable_none(): +def test_justify_renderable_none() -> None: console = Console( file=io.StringIO(), force_terminal=True, @@ -451,7 +451,7 @@ def test_justify_renderable_none(): assert console.file.getvalue() == "╭───╮\n│FOO│\n╰───╯\n" -def test_justify_renderable_left(): +def test_justify_renderable_left() -> None: console = Console( file=io.StringIO(), force_terminal=True, @@ -463,7 +463,7 @@ def test_justify_renderable_left(): assert console.file.getvalue() == "╭───╮ \n│FOO│ \n╰───╯ \n" -def test_justify_renderable_center(): +def test_justify_renderable_center() -> None: console = Console( file=io.StringIO(), force_terminal=True, @@ -475,7 +475,7 @@ def test_justify_renderable_center(): assert console.file.getvalue() == " ╭───╮ \n │FOO│ \n ╰───╯ \n" -def test_justify_renderable_right(): +def test_justify_renderable_right() -> None: console = Console( file=io.StringIO(), force_terminal=True, @@ -495,14 +495,14 @@ def __rich_console__(self, console, options): pass -def test_render_broken_renderable(): +def test_render_broken_renderable() -> None: console = Console() broken = BrokenRenderable() with pytest.raises(errors.NotRenderableError): list(console.render(broken, console.options)) -def test_export_text(): +def test_export_text() -> None: console = Console(record=True, width=100) console.print("[b]foo") text = console.export_text() @@ -510,7 +510,7 @@ def test_export_text(): assert text == expected -def test_export_html(): +def test_export_html() -> None: console = Console(record=True, width=100) console.print("[b]foo