added pretty functionality

This commit is contained in:
Will McGugan 2020-08-07 23:14:52 +01:00
parent 236338a76b
commit 6dfdf1a40c
12 changed files with 316 additions and 130 deletions

View File

@ -5,13 +5,22 @@ 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).
## [5.0.1] - Unreleased
## [5.1.0] - Unreleased
### Added
- Added Text.cell_len
- Added helpful message regarding unicode decoding errors
### Fixed
- Fixed deprecation warnings re backslash https://github.com/willmcgugan/rich/issues/210
- Fixed repr highlighting of scientific notation, e.g. 1e100
### Changed
- Implemented pretty printing, and removed pprintpp from dependancies
## [5.0.0] - 2020-08-02
### Changed

23
poetry.lock generated
View File

@ -1,7 +1,7 @@
[[package]]
category = "main"
description = "Disable App Nap on OS X 10.9"
marker = "python_version >= \"3.3\" and sys_platform == \"darwin\" or platform_system == \"Darwin\""
marker = "sys_platform == \"darwin\" or platform_system == \"Darwin\" or python_version >= \"3.3\" and sys_platform == \"darwin\""
name = "appnope"
optional = true
python-versions = "*"
@ -24,7 +24,6 @@ tests = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.i
[[package]]
category = "main"
description = "Specifications for callback functions passed in to an API"
marker = "python_version >= \"3.3\""
name = "backcall"
optional = true
python-versions = "*"
@ -193,7 +192,6 @@ test = ["pytest (>=3.6.0)", "pytest-cov", "mock"]
[[package]]
category = "main"
description = "An autocompletion tool for Python that can be used for text editors."
marker = "python_version >= \"3.3\""
name = "jedi"
optional = true
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
@ -394,7 +392,7 @@ testing = ["docopt", "pytest (>=3.0.7)"]
[[package]]
category = "main"
description = "Pexpect allows easy control of interactive console applications."
marker = "python_version >= \"3.3\" and sys_platform != \"win32\""
marker = "python_version >= \"3.3\" and sys_platform != \"win32\" or sys_platform != \"win32\""
name = "pexpect"
optional = true
python-versions = "*"
@ -406,20 +404,11 @@ ptyprocess = ">=0.5"
[[package]]
category = "main"
description = "Tiny 'shelve'-like database with concurrency support"
marker = "python_version >= \"3.3\""
name = "pickleshare"
optional = true
python-versions = "*"
version = "0.7.5"
[[package]]
category = "main"
description = "A drop-in replacement for pprint that's actually pretty"
name = "pprintpp"
optional = false
python-versions = "*"
version = "0.4.0"
[[package]]
category = "main"
description = "Python client for the Prometheus monitoring system."
@ -434,7 +423,6 @@ twisted = ["twisted"]
[[package]]
category = "main"
description = "Library for building powerful interactive command lines in Python"
marker = "python_version >= \"3.3\""
name = "prompt-toolkit"
optional = true
python-versions = ">=3.6"
@ -633,7 +621,8 @@ testing = ["jaraco.itertools", "func-timeout"]
jupyter = ["ipywidgets"]
[metadata]
content-hash = "e2c24649506040755aa391e68196f00a65ec8e7f7130feb9254a3b6d6352bb72"
content-hash = "64c8b886ebf3d4fec7647f65af7ebf6b20e031fbec205d96af3c937ff686c415"
lock-version = "1.0"
python-versions = "^3.6"
[metadata.files]
@ -787,10 +776,6 @@ pickleshare = [
{file = "pickleshare-0.7.5-py2.py3-none-any.whl", hash = "sha256:9649af414d74d4df115d5d718f82acb59c9d418196b7b4290ed47a12ce62df56"},
{file = "pickleshare-0.7.5.tar.gz", hash = "sha256:87683d47965c1da65cdacaf31c8441d12b8044cdec9aca500cd78fc2c683afca"},
]
pprintpp = [
{file = "pprintpp-0.4.0-py2.py3-none-any.whl", hash = "sha256:b6b4dcdd0c0c0d75e4d7b2f21a9e933e5b2ce62b26e1a54537f9651ae5a5c01d"},
{file = "pprintpp-0.4.0.tar.gz", hash = "sha256:ea826108e2c7f49dc6d66c752973c3fc9749142a798d6b254e1e301cfdbc6403"},
]
prometheus-client = [
{file = "prometheus_client-0.8.0-py2.py3-none-any.whl", hash = "sha256:983c7ac4b47478720db338f1491ef67a100b474e3bc7dafcbaefb7d0b8f9b01c"},
{file = "prometheus_client-0.8.0.tar.gz", hash = "sha256:c6e6b706833a6bd1fd51711299edee907857be10ece535126a158f911ee80915"},

View File

@ -2,7 +2,7 @@
name = "rich"
homepage = "https://github.com/willmcgugan/rich"
documentation = "https://rich.readthedocs.io/en/latest/"
version = "5.0.1"
version = "5.1.0"
description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
authors = ["Will McGugan <willmcgugan@gmail.com>"]
license = "MIT"
@ -23,7 +23,6 @@ include = ["rich/py.typed"]
[tool.poetry.dependencies]
python = "^3.6"
pprintpp = "^0.4.0"
typing-extensions = "^3.7.4"
dataclasses = {version="^0.7", python = "~3.6"}
pygments = "^2.6.0"

View File

@ -984,14 +984,18 @@ class Console:
else:
text = self._render_buffer()
if text:
if WINDOWS: # pragma: no cover
# https://bugs.python.org/issue37871
write = self.file.write
for line in text.splitlines(True):
write(line)
else:
self.file.write(text)
self.file.flush()
try:
if WINDOWS: # pragma: no cover
# https://bugs.python.org/issue37871
write = self.file.write
for line in text.splitlines(True):
write(line)
else:
self.file.write(text)
self.file.flush()
except UnicodeEncodeError as error:
error.reason = f"{error.reason}\n*** You may need to add PYTHONIOENCODING=utf-8 to your environment ***"
raise
def _render_buffer(self) -> str:
"""Render buffered output, and clear buffer."""

View File

@ -47,8 +47,10 @@ DEFAULT_STYLES: Dict[str, Style] = {
"log.time": Style(color="cyan", dim=True),
"log.message": Style(),
"log.path": Style(dim=True),
"repr.error": Style(color="red", bold=True),
"repr.str": Style(color="green", italic=False, bold=False),
"repr.brace": Style(bold=True),
"repr.comma": Style(bold=True),
"repr.tag_start": Style(bold=True),
"repr.tag_name": Style(color="bright_magenta", bold=True),
"repr.tag_contents": Style(color="default"),

View File

@ -1,9 +1,12 @@
from dataclasses import dataclass
import sys
from typing import Any, Iterable, List, Optional, Tuple, TYPE_CHECKING
from dataclasses import dataclass, field
from rich.highlighter import ReprHighlighter
from pprintpp import pformat
from typing import Any, Dict, List, Optional, Set, TYPE_CHECKING
from .cells import cell_len
from .highlighter import Highlighter, NullHighlighter, ReprHighlighter
from ._loop import loop_last
from .measure import Measurement
from .text import Text
@ -12,101 +15,250 @@ if TYPE_CHECKING: # pragma: no cover
from .console import Console, ConsoleOptions, HighlighterType, RenderResult
def install(console: "Console" = None) -> None:
"""Install automatic pretty printing in the Python REPL."""
from rich import get_console
console = console or get_console()
def display_hook(value: Any) -> None:
if value is not None:
console.print(
value
if hasattr(value, "__rich_console__") or hasattr(value, "__rich__")
else pretty_repr(value)
)
sys.displayhook = display_hook
class Pretty:
"""A rich renderable that pretty prints an object."""
def __init__(self, _object: Any, highlighter: "HighlighterType" = None) -> None:
self._object = _object
self.highlighter = highlighter or Text
self.highlighter = highlighter or NullHighlighter()
def __rich_console__(
self, console: "Console", options: "ConsoleOptions"
) -> "RenderResult":
# TODO: pformat tends to render a smaller width than it needs to, investigate why
print(options)
_min, max_width = Measurement.get(console, self, options.max_width)
pretty_str = pformat(self._object, width=max_width)
pretty_str = pretty_str.replace("\r", "")
pretty_text = self.highlighter(pretty_str)
pretty_text = pretty_repr(self._object, max_width=options.max_width)
yield pretty_text
def __rich_measure__(self, console: "Console", max_width: int) -> "Measurement":
pretty_str = pformat(self._object, width=max_width)
pretty_str = pretty_str.replace("\r", "")
text = Text(pretty_str)
measurement = Measurement.get(console, text, max_width)
print(measurement)
return measurement
@dataclass
class _Node:
indent: int = -1
object_repr: Optional[str] = None
name: str = ""
braces: Optional[Tuple[str, str]] = None
values: Optional[List["_Node"]] = None
items: Optional[List[Tuple[str, "_Node"]]] = None
expanded: bool = False
pretty_text = pretty_repr(self._object, max_width=max_width)
text_width = max(cell_len(line) for line in pretty_text.plain.splitlines())
return Measurement(text_width, text_width)
_BRACES = {
list: ("", "[", "]"),
tuple: ("", "(", ")"),
set: ("", "{", "}"),
frozenset: ("frozenset", "({", "})"),
dict: (Text("{", "repr.brace"), Text("}", "repr.brace")),
frozenset: (
Text.assemble("frozenset(", ("{", "repr.brace")),
Text.assemble(("}", "repr.brace"), ")"),
),
list: (Text("[", "repr.brace"), Text("]", "repr.brace")),
set: (Text("{", "repr.brace"), Text("}", "repr.brace")),
tuple: (Text("(", "repr.brace"), Text(")", "repr.brace")),
}
_CONTAINERS = tuple(_BRACES.keys())
_REPR_STYLES = {
type(None): "repr.none",
str: "repr.str",
float: "repr.number",
int: "repr.number",
}
@dataclass
class _Value:
object_repr: str
class _Line:
"""A line in a pretty repr."""
parts: List[Text] = field(default_factory=list)
_cell_len: int = 0
def append(self, text: Text) -> None:
"""Add text to line."""
# Efficiently keep track of cell length
self.parts.append(text)
self._cell_len += text.cell_len
@property
def cell_len(self) -> int:
return (
self._cell_len
if self.parts and not self.parts[-1].plain.endswith(" ")
else self._cell_len - 1
)
@property
def text(self):
"""The text as a while."""
return Text("").join(self.parts)
@dataclass
class _Container:
braces: Tuple[str]
def pretty_repr(
_object: Any,
*,
max_width: Optional[int] = 80,
indent_size: int = 4,
highlighter: Highlighter = None,
) -> Text:
"""Return a 'pretty' repr.
Args:
_object (Any): Object to repr.
max_width (int, optional): Maximum desired width. Defaults to 80.
indent_size (int, optional): Number of spaces in an indent. Defaults to 4.
highlighter (Highlighter, optional): A highlighter for repr strings. Defaults to ReprHighlighter.
def pretty_repr(_object: Any, *, width: int = 80, indent_size: int = 4) -> str:
Returns:
Text: A Text instance conaining a pretty repr.
"""
class MaxLineReached(Exception):
"""Line is greater than maximum"""
def __init__(self, line_no: int) -> None:
self.line_no = line_no
super().__init__()
if highlighter is None:
highlighter = ReprHighlighter()
indent = " " * indent_size
stack = []
push = stack.append
expand_level = 0
node = _object
lines: List[_Line] = [_Line()]
if isinstance(node, (tuple, list, set, dict)):
braces = _BRACES[type(node)]
push(_Container())
visited_set: Set[int] = set()
repr_cache: Dict[int, Text] = {}
repr_cache_get = repr_cache.get
def add_node(parent_node: _Node, node_object: Any) -> _Node:
if isinstance(node_object, (list, set, frozenset, tuple)):
name, open_brace, close_brace = _BRACES[type(node_object)]
node = _Node(
indent=parent_node.indent + 1, braces=(open_brace, close_brace)
)
node.values = [add_node(node, child) for child in node_object]
elif isinstance(node_object, dict):
node = _Node(indent=parent_node.indent + 1, braces=("{", "}"))
node.items = [
(key, add_node(node, value)) for key, value in node_object.items()
]
def to_repr_text(node: Any) -> Text:
node_id = id(node)
cached = repr_cache_get(node_id)
if cached is not None:
return cached
style: Optional[str]
if node is True:
style = "repr.bool_true"
elif node is False:
style = "repr.bool_false"
else:
node = _Node(indent=parent_node.indent + 1, object_repr=repr(node_object))
return node
style = _REPR_STYLES.get(type(node))
try:
repr_text = repr(node)
except Exception as error:
text = Text(f"<error in repr: {error}>", "repr.error")
else:
if style is None:
text = highlighter(Text(repr_text))
else:
text = Text(repr_text, style)
repr_cache[node_id] = text
visited_set.add(node_id)
return text
node = add_node(_Node(), _object)
print(node)
node.expanded = True
comma = Text(", ")
colon = Text(": ")
line_break: Optional[int] = None
output = []
def traverse(node: Any, level: int = 0) -> None:
nonlocal line_break
return ""
append_line = lines.append
def append_text(text: Text) -> None:
nonlocal line_break
line = lines[-1]
line.append(text)
if max_width is not None and line.cell_len > max_width:
if line_break is not None and len(lines) <= line_break:
return
line_break = len(lines)
raise MaxLineReached(level)
node_id = id(node)
if node_id in visited_set:
append_text(Text("...", "repr.error"))
return
visited_set.add(node_id)
if type(node) in _CONTAINERS:
brace_open, brace_close = _BRACES[type(node)]
expanded = level < expand_level
append_text(brace_open)
if isinstance(node, dict):
for last, (key, value) in loop_last(node.items()):
if expanded:
append_line(_Line())
append_text(Text(indent * (level + 1)))
append_text(to_repr_text(key))
append_text(colon)
traverse(value, level + 1)
if not last:
append_text(comma)
else:
for last, value in loop_last(node):
if expanded:
append_line(_Line())
append_text(Text(indent * (level + 1)))
traverse(value, level + 1)
if not last:
append_text(comma)
if expanded:
lines.append(_Line())
append_text(Text.assemble(f"{indent * level}", brace_close))
else:
append_text(brace_close)
else:
append_text(to_repr_text(node))
visited_set.remove(node_id)
while True:
try:
traverse(_object)
except MaxLineReached as max_line:
del lines[:]
visited_set.clear()
lines.append(_Line())
expand_level += 1
else:
break # pragma: no cover
return Text("\n").join(line.text for line in lines)
if __name__ == "__main__":
data = {"d": [1, "Hello World!", 2, 3, 4, {5, 6, 7, (1, 2, 3, 4), 8}]}
from collections import defaultdict
class BrokenRepr:
def __repr__(self):
1 / 0
d = defaultdict(int)
d["foo"] = 5
data = {
"foo": [1, "Hello World!", 2, 3, 4, {5, 6, 7, (1, 2, 3, 4), 8}],
"bar": frozenset({1, 2, 3}),
False: "This is false",
True: "This is true",
None: "This is None",
"Broken": BrokenRepr(),
}
data["foo"].append(data)
from rich.console import Console
console = Console()
print("-" * console.width)
from rich import print
print(pretty_repr(data))
p = Pretty(data)
print(Measurement.get(console, p))
console.print(p)

View File

@ -13,11 +13,11 @@ class PromptError(Exception):
class InvalidResponse(PromptError):
"""Exception to indicate a response was invalid. Raise this within process_respons to indicate an error
"""Exception to indicate a response was invalid. Raise this within process_response() to indicate an error
and provide an error message.
Args:
message (str): Error message.
message (Union[str, Text]): Error message.
"""
def __init__(self, message: TextType) -> None:

View File

@ -27,7 +27,7 @@ def render_scope(
RenderableType: A renderable object.
"""
highlighter = ReprHighlighter()
items_table = Table.grid(padding=(0, 1))
items_table = Table.grid(padding=(0, 1), expand=False)
items_table.add_column(justify="right")
def sort_items(item: Tuple[str, Any]) -> Tuple[bool, str]:

View File

@ -432,6 +432,12 @@ class Table(JupyterMixin):
widths = ratio_reduce(excess_width, [1] * len(widths), widths, widths)
table_width = sum(widths)
width_ranges = [
self._measure_column(console, column, width)
for width, column in zip(widths, columns)
]
widths = [_range.maximum or 1 for _range in width_ranges]
if table_width < max_width and self.expand:
pad_widths = ratio_distribute(max_width - table_width, widths)
widths = [_width + pad for _width, pad in zip(widths, pad_widths)]

View File

@ -180,6 +180,11 @@ class Text(JupyterMixin):
return other.plain in self.plain
return False
@property
def cell_len(self) -> int:
"""Get the number of cells required to render this text."""
return cell_len(self.plain)
@classmethod
def from_markup(
cls,
@ -560,11 +565,16 @@ class Text(JupyterMixin):
"""
new_text = self.blank_copy()
append = new_text.append
for last, line in loop_last(lines):
append(line)
if not last:
append(self)
append = new_text.append_text
if self.plain:
for last, line in loop_last(lines):
append(line)
if not last:
append(self)
else:
for line in lines:
append(line)
return new_text
def tabs_to_spaces(self, tab_size: int = None) -> "Text":
@ -716,38 +726,57 @@ class Text(JupyterMixin):
style (str, optional): A style name. Defaults to None.
Returns:
text (Text): Returns self for chaining.
Text: Returns self for chaining.
"""
if not isinstance(text, (str, Text)):
raise TypeError("Only str or Text can be appended to Text")
if not len(text):
return self
if isinstance(text, str):
text = strip_control_codes(text)
self._text.append(text)
offset = len(self)
text_length = len(text)
if style is not None:
self._spans.append(Span(offset, offset + text_length, style))
self._length += text_length
elif isinstance(text, Text):
_Span = Span
if style is not None:
raise ValueError("style must not be set when appending Text instance")
if len(text):
if isinstance(text, str):
text = strip_control_codes(text)
self._text.append(text)
offset = len(self)
text_length = len(text)
if style is not None:
self._spans.append(Span(offset, offset + text_length, style))
self._length += text_length
elif isinstance(text, Text):
_Span = Span
if style is not None:
raise ValueError(
"style must not be set when appending Text instance"
)
text_length = self._length
if text.style is not None:
self._spans.append(
_Span(text_length, text_length + len(text), text.style)
text_length = self._length
if text.style is not None:
self._spans.append(
_Span(text_length, text_length + len(text), text.style)
)
self._text.append(text.plain)
self._spans.extend(
_Span(start + text_length, end + text_length, style)
for start, end, style in text._spans
)
self._text.append(text.plain)
self._spans.extend(
_Span(start + text_length, end + text_length, style)
for start, end, style in text._spans
)
self._length += len(text)
self._length += len(text)
return self
def append_text(self, text: "Text") -> "Text":
"""Append another Text instance. This method is more performant that Text.append.
Returns:
Text: Returns self for chaining.
"""
_Span = Span
text_length = self._length
if text.style is not None:
self._spans.append(_Span(text_length, text_length + len(text), text.style))
self._text.append(text.plain)
self._spans.extend(
_Span(start + text_length, end + text_length, style)
for start, end, style in text._spans
)
self._length += len(text)
return self
def copy_styles(self, text: "Text") -> None:

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long