From 2320989da8f7488f1150fe0fa3196aee36b69f90 Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Wed, 25 May 2022 10:41:45 +0100 Subject: [PATCH] [text] Replace ASCII special characters with their "non-special" readable version With this we allow the following: - Such characters will be displayed in the data returned by "rich.inspect" when they are used in docstrings - We fix the crash that can happen in some circumstances in the `Text.divide()` method when some docstrings have such special characters --- CHANGELOG.md | 6 ++++++ rich/_inspect.py | 14 -------------- rich/control.py | 38 +++++++++++++++++++++++++++++++++++--- rich/text.py | 19 ++++++++++--------- tests/test_control.py | 11 ++++++++++- tests/test_text.py | 6 +++--- 6 files changed, 64 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52db0457b..8eaaf508d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- Fix crashes that can happen with `inspect` when docstrings contain some special control codes https://github.com/Textualize/rich/pull/2294 + ## [12.4.4] - 2022-05-24 ### Changed diff --git a/rich/_inspect.py b/rich/_inspect.py index 3b77d6393..da4998b4b 100644 --- a/rich/_inspect.py +++ b/rich/_inspect.py @@ -12,19 +12,6 @@ from .table import Table from .text import Text, TextType -_SPECIAL_CHARACTERS_TRANSLATION_TABLE = str.maketrans( - # Some special characters (such as "\b", aka "Backspace") are stripped from the docstrings when - # we display them, and also mess up our processing - # --> let's replace them with a readable "non-special" version of them (i.e. "\b" -> "\\b"): - { - "\a": "\\a", # ASCII Bell - "\b": "\\b", # ASCII Backspace - "\f": "\\f", # ASCII Formfeed - "\r": "\\r", # ASCII Carriage Return - "\v": "\\v", # ASCII Vertical Tab - } -) - def _first_paragraph(doc: str) -> str: """Get the first paragraph from a docstring.""" @@ -230,5 +217,4 @@ def _get_formatted_doc(self, object_: Any) -> Optional[str]: docs = cleandoc(docs).strip() if not self.help: docs = _first_paragraph(docs) - docs = docs.translate(_SPECIAL_CHARACTERS_TRANSLATION_TABLE) return docs diff --git a/rich/control.py b/rich/control.py index 747311fce..8d56324ae 100644 --- a/rich/control.py +++ b/rich/control.py @@ -1,19 +1,35 @@ +import sys import time from typing import TYPE_CHECKING, Callable, Dict, Iterable, List, Union +if sys.version_info >= (3, 8): + from typing import Final +else: + from typing_extensions import Final # pragma: no cover + from .segment import ControlCode, ControlType, Segment if TYPE_CHECKING: from .console import Console, ConsoleOptions, RenderResult -STRIP_CONTROL_CODES = [ +STRIP_CONTROL_CODES: Final = [ + 7, # Bell 8, # Backspace 11, # Vertical tab 12, # Form feed 13, # Carriage return ] -_CONTROL_TRANSLATE = {_codepoint: None for _codepoint in STRIP_CONTROL_CODES} +_CONTROL_STRIP_TRANSLATE: Final = { + _codepoint: None for _codepoint in STRIP_CONTROL_CODES +} +_CONTROL_MAKE_READABLE_TRANSLATE: Final = { + 7: "\\a", + 8: "\\b", + 11: "\\v", + 12: "\\f", + 13: "\\r", +} CONTROL_CODES_FORMAT: Dict[int, Callable[..., str]] = { ControlType.BELL: lambda: "\x07", @@ -169,7 +185,7 @@ def __rich_console__( def strip_control_codes( - text: str, _translate_table: Dict[int, None] = _CONTROL_TRANSLATE + text: str, _translate_table: Dict[int, None] = _CONTROL_STRIP_TRANSLATE ) -> str: """Remove control codes from text. @@ -182,6 +198,22 @@ def strip_control_codes( return text.translate(_translate_table) +def make_control_codes_readable( + text: str, + _translate_table: Dict[int, str] = _CONTROL_MAKE_READABLE_TRANSLATE, +) -> str: + """Replace control codes with their "readable" equivalent in the given text. + (e.g. "\b" becomes "\\b") + + Args: + text (str): A string possibly contain control codes. + + Returns: + str: String with control codes replaced with a readable version. + """ + return text.translate(_translate_table) + + if __name__ == "__main__": # pragma: no cover from rich.console import Console diff --git a/rich/text.py b/rich/text.py index f6b349d6a..2060fae5e 100644 --- a/rich/text.py +++ b/rich/text.py @@ -2,7 +2,6 @@ from functools import partial, reduce from math import gcd from operator import itemgetter -from rich.emoji import EmojiVariant from typing import ( TYPE_CHECKING, Any, @@ -22,7 +21,7 @@ from .align import AlignMethod from .cells import cell_len, set_cell_size from .containers import Lines -from .control import strip_control_codes +from .control import make_control_codes_readable from .emoji import EmojiVariant from .jupyter import JupyterMixin from .measure import Measurement @@ -141,7 +140,8 @@ def __init__( tab_size: Optional[int] = 8, spans: Optional[List[Span]] = None, ) -> None: - self._text = [strip_control_codes(text)] + sanitized_text = make_control_codes_readable(text) + self._text = [sanitized_text] self.style = style self.justify: Optional["JustifyMethod"] = justify self.overflow: Optional["OverflowMethod"] = overflow @@ -149,7 +149,7 @@ def __init__( self.end = end self.tab_size = tab_size self._spans: List[Span] = spans or [] - self._length: int = len(text) + self._length: int = len(sanitized_text) def __len__(self) -> int: return self._length @@ -394,9 +394,10 @@ def plain(self) -> str: def plain(self, new_text: str) -> None: """Set the text to a new value.""" if new_text != self.plain: - self._text[:] = [new_text] + sanitized_text = make_control_codes_readable(new_text) + self._text[:] = [sanitized_text] old_length = self._length - self._length = len(new_text) + self._length = len(sanitized_text) if old_length > self._length: self._trim_spans() @@ -906,10 +907,10 @@ def append( if len(text): if isinstance(text, str): - text = strip_control_codes(text) - self._text.append(text) + sanitized_text = make_control_codes_readable(text) + self._text.append(sanitized_text) offset = len(self) - text_length = len(text) + text_length = len(sanitized_text) if style is not None: self._spans.append(Span(offset, offset + text_length, style)) self._length += text_length diff --git a/tests/test_control.py b/tests/test_control.py index 987206d82..aec42ce03 100644 --- a/tests/test_control.py +++ b/tests/test_control.py @@ -1,4 +1,4 @@ -from rich.control import Control, strip_control_codes +from rich.control import Control, make_control_codes_readable, strip_control_codes from rich.segment import ControlType, Segment @@ -13,6 +13,15 @@ def test_strip_control_codes(): assert strip_control_codes("Fear is the mind killer") == "Fear is the mind killer" +def test_make_control_codes_readable(): + assert make_control_codes_readable("") == "" + assert make_control_codes_readable("foo\rbar") == "foo\\rbar" + assert ( + make_control_codes_readable("Fear is the mind killer") + == "Fear is the mind killer" + ) + + def test_control_move_to(): control = Control.move_to(5, 10) print(control.segment) diff --git a/tests/test_text.py b/tests/test_text.py index 4f02fd41e..f4d4bc139 100644 --- a/tests/test_text.py +++ b/tests/test_text.py @@ -583,11 +583,11 @@ def test_styled(): assert text._spans == [Span(0, 3, "bold red")] -def test_strip_control_codes(): +def test_make_control_codes_readable(): text = Text("foo\rbar") - assert str(text) == "foobar" + assert str(text) == "foo\\rbar" text.append("\x08") - assert str(text) == "foobar" + assert str(text) == "foo\\rbar\\b" def test_get_style_at_offset():