From 78a02294670ddd7091a4050b30a40029db3fc923 Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Wed, 25 May 2022 09:51:08 +0100 Subject: [PATCH 1/3] [inspect] Replace ASCII special characters with their "non-special" readable version in docstrings With this we allow the following: - Such characters will be displayed in the data returned by "rich.inspect" - We fix the crash that can happen in some circumstances in the `Text.divide()` method when some docstrings have such special characters --- pyproject.toml | 3 +++ rich/_inspect.py | 44 +++++++++++++++++++++++++++---------------- tests/test_inspect.py | 38 +++++++++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 16 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1b16acf9b..f2edc90aa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,3 +61,6 @@ enable_error_code = ["ignore-without-code", "redundant-expr", "truthy-bool"] [[tool.mypy.overrides]] module = ["pygments.*", "IPython.*", "commonmark.*", "ipywidgets.*"] ignore_missing_imports = true + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/rich/_inspect.py b/rich/_inspect.py index 01713e576..3b77d6393 100644 --- a/rich/_inspect.py +++ b/rich/_inspect.py @@ -12,6 +12,19 @@ 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.""" @@ -19,12 +32,6 @@ def _first_paragraph(doc: str) -> str: return paragraph -def _reformat_doc(doc: str) -> str: - """Reformat docstring.""" - doc = cleandoc(doc).strip() - return doc - - class Inspect(JupyterMixin): """A renderable to inspect any Python Object. @@ -161,11 +168,9 @@ def safe_getattr(attr_name: str) -> Tuple[Any, Any]: yield "" if self.docs: - _doc = getdoc(obj) + _doc = self._get_formatted_doc(obj) if _doc is not None: - if not self.help: - _doc = _first_paragraph(_doc) - doc_text = Text(_reformat_doc(_doc), style="inspect.help") + doc_text = Text(_doc, style="inspect.help") doc_text = highlighter(doc_text) yield doc_text yield "" @@ -200,13 +205,10 @@ def safe_getattr(attr_name: str) -> Tuple[Any, Any]: add_row(key_text, Pretty(value, highlighter=highlighter)) else: if self.docs: - docs = getdoc(value) + docs = self._get_formatted_doc(value) if docs is not None: - _doc = _reformat_doc(str(docs)) - if not self.help: - _doc = _first_paragraph(_doc) - _signature_text.append("\n" if "\n" in _doc else " ") - doc = highlighter(_doc) + _signature_text.append("\n" if "\n" in docs else " ") + doc = highlighter(docs) doc.stylize("inspect.doc") _signature_text.append(doc) @@ -220,3 +222,13 @@ def safe_getattr(attr_name: str) -> Tuple[Any, Any]: f"[b cyan]{not_shown_count}[/][i] attribute(s) not shown.[/i] " f"Run [b][magenta]inspect[/]([not b]inspect[/])[/b] for options." ) + + def _get_formatted_doc(self, object_: Any) -> Optional[str]: + docs = getdoc(object_) + if docs is None: + return None + docs = cleandoc(docs).strip() + if not self.help: + docs = _first_paragraph(docs) + docs = docs.translate(_SPECIAL_CHARACTERS_TRANSLATION_TABLE) + return docs diff --git a/tests/test_inspect.py b/tests/test_inspect.py index b4c1d2a70..d2e2d86c9 100644 --- a/tests/test_inspect.py +++ b/tests/test_inspect.py @@ -299,3 +299,41 @@ class Thing: "╰──────────────────────────────────────────╯\n" ) assert render(module, methods=True) == expected + + +@pytest.mark.parametrize( + "special_character,expected_replacement", + ( + ("\a", "\\a"), + ("\b", "\\b"), + ("\f", "\\f"), + ("\r", "\\r"), + ("\v", "\\v"), + ), +) +def test_can_handle_special_characters_in_docstrings( + special_character: str, expected_replacement: str +): + class Something: + class Thing: + pass + + Something.Thing.__doc__ = f""" + Multiline docstring + with {special_character} should be handled + """ + + expected = """\ +╭─ .Something(): │ +│ │ +│ Thing = class Thing(): │ +│ Multiline docstring │ +│ with %s should be handled │ +╰────────────────────────────────────────────────╯ +""" % ( + expected_replacement + ) + + assert render(Something, methods=True) == expected From 236096ea8fcc0fc5d919a80ad9be78ea6151429e Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Wed, 25 May 2022 10:41:45 +0100 Subject: [PATCH 2/3] [inspect] 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 | 1 + rich/_inspect.py | 17 ++--------------- rich/control.py | 38 +++++++++++++++++++++++++++++++++++--- rich/text.py | 17 +++++++++-------- tests/test_control.py | 11 ++++++++++- 5 files changed, 57 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc33feab4..f8083da2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Allow exceptions that are raised while a Live is rendered to be displayed and/or processed https://github.com/Textualize/rich/pull/2305 +- 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 diff --git a/rich/_inspect.py b/rich/_inspect.py index 3b77d6393..bb8894615 100644 --- a/rich/_inspect.py +++ b/rich/_inspect.py @@ -5,6 +5,7 @@ from typing import Any, Iterable, Optional, Tuple from .console import Group, RenderableType +from .control import make_control_codes_readable from .highlighter import ReprHighlighter from .jupyter import JupyterMixin from .panel import Panel @@ -12,19 +13,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 +218,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 + return make_control_codes_readable(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..1471fac2d 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, @@ -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 = strip_control_codes(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 = strip_control_codes(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 = strip_control_codes(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) From 25d0c0e89829685d2be17043812de82e97c460f7 Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Fri, 27 May 2022 14:55:05 +0100 Subject: [PATCH 3/3] [inspect] Address PR feedback on ASCII special characters' replacement with their escaped version --- rich/_inspect.py | 16 ++++++++++++++-- rich/control.py | 12 ++++++------ tests/test_control.py | 13 +++++-------- 3 files changed, 25 insertions(+), 16 deletions(-) diff --git a/rich/_inspect.py b/rich/_inspect.py index bb8894615..af4403407 100644 --- a/rich/_inspect.py +++ b/rich/_inspect.py @@ -5,7 +5,7 @@ from typing import Any, Iterable, Optional, Tuple from .console import Group, RenderableType -from .control import make_control_codes_readable +from .control import escape_control_codes from .highlighter import ReprHighlighter from .jupyter import JupyterMixin from .panel import Panel @@ -212,10 +212,22 @@ def safe_getattr(attr_name: str) -> Tuple[Any, Any]: ) def _get_formatted_doc(self, object_: Any) -> Optional[str]: + """ + Extract the docstring of an object, process it and returns it. + The processing consists in cleaning up the doctring's indentation, + taking only its 1st paragraph if `self.help` is not True, + and escape its control codes. + + Args: + object_ (Any): the object to get the docstring from. + + Returns: + Optional[str]: the processed docstring, or None if no docstring was found. + """ docs = getdoc(object_) if docs is None: return None docs = cleandoc(docs).strip() if not self.help: docs = _first_paragraph(docs) - return make_control_codes_readable(docs) + return escape_control_codes(docs) diff --git a/rich/control.py b/rich/control.py index 8d56324ae..a8a912553 100644 --- a/rich/control.py +++ b/rich/control.py @@ -23,7 +23,7 @@ _codepoint: None for _codepoint in STRIP_CONTROL_CODES } -_CONTROL_MAKE_READABLE_TRANSLATE: Final = { +CONTROL_ESCAPE: Final = { 7: "\\a", 8: "\\b", 11: "\\v", @@ -198,18 +198,18 @@ def strip_control_codes( return text.translate(_translate_table) -def make_control_codes_readable( +def escape_control_codes( text: str, - _translate_table: Dict[int, str] = _CONTROL_MAKE_READABLE_TRANSLATE, + _translate_table: Dict[int, str] = CONTROL_ESCAPE, ) -> str: - """Replace control codes with their "readable" equivalent in the given text. + """Replace control codes with their "escaped" equivalent in the given text. (e.g. "\b" becomes "\\b") Args: - text (str): A string possibly contain control codes. + text (str): A string possibly containing control codes. Returns: - str: String with control codes replaced with a readable version. + str: String with control codes replaced with their escaped version. """ return text.translate(_translate_table) diff --git a/tests/test_control.py b/tests/test_control.py index aec42ce03..d2704e980 100644 --- a/tests/test_control.py +++ b/tests/test_control.py @@ -1,4 +1,4 @@ -from rich.control import Control, make_control_codes_readable, strip_control_codes +from rich.control import Control, escape_control_codes, strip_control_codes from rich.segment import ControlType, Segment @@ -13,13 +13,10 @@ 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_escape_control_codes(): + assert escape_control_codes("") == "" + assert escape_control_codes("foo\rbar") == "foo\\rbar" + assert escape_control_codes("Fear is the mind killer") == "Fear is the mind killer" def test_control_move_to():