diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e43293b8..938d4628b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +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). -## [10.3.1] - Unreleased +## [10.4.0] - 2021-06-18 ### Added - Added Style.meta +- Added rich.repr.auto decorator ### Fixed diff --git a/docs/source/pretty.rst b/docs/source/pretty.rst index 01fdcd613..7929150a0 100644 --- a/docs/source/pretty.rst +++ b/docs/source/pretty.rst @@ -67,24 +67,28 @@ Rich Repr Protocol Rich is able to syntax highlight any output, but the formatting is restricted to builtin containers, dataclasses, and other objects Rich knows about, such as objects generated by the `attrs `_ library. To add Rich formatting capabilities to custom objects, you can implement the *rich repr protocol*. +Run the following command to see an example of what the Rich repr protocol can generate:: + + python -m rich.repr + First, let's look at a class that might benefit from a Rich repr:: - class Bird: - def __init__(self, name, eats=None, fly=True, extinct=False): - self.name = name - self.eats = list(eats) if eats else [] - self.fly = fly - self.extinct = extinct +class Bird: + def __init__(self, name, eats=None, fly=True, extinct=False): + self.name = name + self.eats = list(eats) if eats else [] + self.fly = fly + self.extinct = extinct - def __repr__(self): - return f"Bird({self.name!r}, eats={self.eats!r}, fly={self.fly!r}, extinct={self.extinct!r})" + def __repr__(self): + return f"Bird({self.name!r}, eats={self.eats!r}, fly={self.fly!r}, extinct={self.extinct!r})" - BIRDS = { - "gull": Bird("gull", eats=["fish", "chips", "ice cream", "sausage rolls"]), - "penguin": Bird("penguin", eats=["fish"], fly=False), - "dodo": Bird("dodo", eats=["fruit"], fly=False, extinct=True) - } - print(BIRDS) +BIRDS = { + "gull": Bird("gull", eats=["fish", "chips", "ice cream", "sausage rolls"]), + "penguin": Bird("penguin", eats=["fish"], fly=False), + "dodo": Bird("dodo", eats=["fruit"], fly=False, extinct=True) +} +print(BIRDS) The result of this script would be:: @@ -168,6 +172,45 @@ This will change the output of the Rich repr example to the following:: Note that you can add ``__rich_repr__`` methods to third-party libraries *without* including Rich as a dependency. If Rich is not installed, then nothing will break. Hopefully more third-party libraries will adopt Rich repr methods in the future. +Automatic Rich Repr +~~~~~~~~~~~~~~~~~~~ + +Rich can generate a rich repr automatically if the parameters are named the same as your attributes. + +To automatically build a rich repr, use the :meth:`~rich.repr.auto` class decorator. The Bird example above follows the above rule, so we wouldn't even need to implement our own `__rich_repr__`:: + + import rich.repr + + @rich.repr.auto + class Bird: + def __init__(self, name, eats=None, fly=True, extinct=False): + self.name = name + self.eats = list(eats) if eats else [] + self.fly = fly + self.extinct = extinct + + + BIRDS = { + "gull": Bird("gull", eats=["fish", "chips", "ice cream", "sausage rolls"]), + "penguin": Bird("penguin", eats=["fish"], fly=False), + "dodo": Bird("dodo", eats=["fruit"], fly=False, extinct=True) + } + from rich import print + print(BIRDS) + +Note that the decorator will also create a `__repr__`, so you you will get an auto-generated repr even if you don't print with Rich. + +If you want to auto-generate the angular type of repr, then set ``angular=True`` on the decorator:: + + @rich.repr.auto(angular=True) + class Bird: + def __init__(self, name, eats=None, fly=True, extinct=False): + self.name = name + self.eats = list(eats) if eats else [] + self.fly = fly + self.extinct = extinct + + Example ------- diff --git a/examples/repr.py b/examples/repr.py index 7af5bdcd2..01226fc5f 100644 --- a/examples/repr.py +++ b/examples/repr.py @@ -1,7 +1,7 @@ -from rich.repr import rich_repr +import rich.repr -@rich_repr +@rich.repr.auto class Bird: def __init__(self, name, eats=None, fly=True, extinct=False): self.name = name @@ -9,14 +9,9 @@ def __init__(self, name, eats=None, fly=True, extinct=False): self.fly = fly self.extinct = extinct - def __rich_repr__(self): - yield self.name - yield "eats", self.eats - yield "fly", self.fly, True - yield "extinct", self.extinct, False - - # __rich_repr__.angular = True +# Note that the repr is still generate without Rich +# Try commenting out the following lin from rich import print diff --git a/pyproject.toml b/pyproject.toml index 3ce74bee5..ad4cbb688 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ name = "rich" homepage = "https://github.com/willmcgugan/rich" documentation = "https://rich.readthedocs.io/en/latest/" -version = "10.3.0" +version = "10.4.0" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" authors = ["Will McGugan "] license = "MIT" diff --git a/rich/color.py b/rich/color.py index a8032ee0d..95cad68c4 100644 --- a/rich/color.py +++ b/rich/color.py @@ -7,6 +7,7 @@ from ._palettes import EIGHT_BIT_PALETTE, STANDARD_PALETTE, WINDOWS_PALETTE from .color_triplet import ColorTriplet +from .repr import rich_repr, RichReprResult from .terminal_theme import DEFAULT_TERMINAL_THEME if TYPE_CHECKING: # pragma: no cover @@ -25,6 +26,9 @@ class ColorSystem(IntEnum): TRUECOLOR = 3 WINDOWS = 4 + def __repr__(self) -> str: + return f"ColorSystem.{self.name}" + class ColorType(IntEnum): """Type of color stored in Color class.""" @@ -35,6 +39,9 @@ class ColorType(IntEnum): TRUECOLOR = 3 WINDOWS = 4 + def __repr__(self) -> str: + return f"ColorType.{self.name}" + ANSI_COLOR_NAMES = { "black": 0, @@ -257,6 +264,7 @@ class ColorParseError(Exception): ) +@rich_repr class Color(NamedTuple): """Terminal color definition.""" @@ -269,13 +277,6 @@ class Color(NamedTuple): triplet: Optional[ColorTriplet] = None """A triplet of color components, if an RGB color.""" - def __repr__(self) -> str: - return ( - f"" - if self.number is None - else f"" - ) - def __rich__(self) -> "Text": """Dispays the actual color if Rich printed.""" from .text import Text @@ -287,6 +288,12 @@ def __rich__(self) -> "Text": " >", ) + def __rich_repr__(self) -> RichReprResult: + yield self.name + yield self.type + yield "number", self.number, None + yield "triplet", self.triplet, None + @property def system(self) -> ColorSystem: """Get the native color system for this color.""" diff --git a/rich/markup.py b/rich/markup.py index 179cabc57..159b104cb 100644 --- a/rich/markup.py +++ b/rich/markup.py @@ -1,6 +1,10 @@ +from ast import literal_eval +from operator import attrgetter import re from typing import Callable, Iterable, List, Match, NamedTuple, Optional, Tuple, Union +from black import E + from .errors import MarkupError from .style import Style from .text import Span, Text @@ -8,7 +12,7 @@ RE_TAGS = re.compile( - r"""((\\*)\[([a-z#\/].*?)\])""", + r"""((\\*)\[([a-z#\/@].*?)\])""", re.VERBOSE, ) @@ -137,6 +141,7 @@ def pop_style(style_name: str) -> Tuple[int, Tag]: elif tag is not None: if tag.name.startswith("/"): # Closing tag style_name = tag.name[1:].strip() + if style_name: # explicit close style_name = normalize(style_name) try: @@ -153,7 +158,30 @@ def pop_style(style_name: str) -> Tuple[int, Tag]: f"closing tag '[/]' at position {position} has nothing to close" ) from None - append_span(_Span(start, len(text), str(open_tag))) + if open_tag.name.startswith("@"): + if open_tag.parameters: + try: + meta_params = literal_eval(open_tag.parameters) + except SyntaxError as error: + raise MarkupError( + f"error parsing {open_tag.parameters!r}; {error.msg}" + ) + except Exception as error: + raise MarkupError( + f"error parsing {open_tag.parameters!r}; {error}" + ) from None + + else: + meta_params = () + + append_span( + _Span( + start, len(text), Style(meta={open_tag.name: meta_params}) + ) + ) + else: + append_span(_Span(start, len(text), str(open_tag))) + else: # Opening tag normalized_tag = _Tag(normalize(tag.name), tag.parameters) style_stack.append((len(text), normalized_tag)) @@ -165,7 +193,7 @@ def pop_style(style_name: str) -> Tuple[int, Tag]: if style: append_span(_Span(start, text_length, style)) - text.spans = sorted(spans) + text.spans = sorted(spans, key=attrgetter("start", "end")) return text @@ -174,7 +202,11 @@ def pop_style(style_name: str) -> Tuple[int, Tag]: from rich.console import Console from rich.text import Text - console = Console(highlight=False) + console = Console(highlight=True) + + t = render("[b]Hello[/b] [@click='view.toggle', 'left']World[/]") + console.print(t) + console.print(t._spans) console.print("Hello [1], [1,2,3] ['hello']") console.print("foo") diff --git a/rich/repr.py b/rich/repr.py index a6d164c97..2236412c8 100644 --- a/rich/repr.py +++ b/rich/repr.py @@ -1,4 +1,17 @@ -from typing import Any, Iterable, List, Union, Tuple, Type, TypeVar +import inspect + +from typing import ( + Any, + Callable, + Iterable, + List, + Optional, + overload, + Union, + Tuple, + Type, + TypeVar, +) T = TypeVar("T") @@ -7,53 +20,101 @@ RichReprResult = Iterable[Union[Any, Tuple[Any], Tuple[str, Any], Tuple[str, Any, Any]]] -def rich_repr(cls: Type[T]) -> Type[T]: +class ReprError(Exception): + """An error occurred when attempting to build a repr.""" + + +@overload +def auto(cls: Type[T]) -> Type[T]: + ... + + +@overload +def auto(*, angular: bool = False) -> Callable[[Type[T]], Type[T]]: + ... + + +def auto( + cls: Optional[Type[T]] = None, *, angular: bool = False +) -> Union[Type[T], Callable[[Type[T]], Type[T]]]: """Class decorator to create __repr__ from __rich_repr__""" - def auto_repr(self: Any) -> str: - repr_str: List[str] = [] - append = repr_str.append - angular = getattr(self.__rich_repr__, "angular", False) - for arg in self.__rich_repr__(): - if isinstance(arg, tuple): - if len(arg) == 1: - append(repr(arg[0])) - else: - key, value, *default = arg - if key is None: - append(repr(value)) + def do_replace(cls: Type[T]) -> Type[T]: + def auto_repr(self: T) -> str: + """Create repr string from __rich_repr__""" + repr_str: List[str] = [] + append = repr_str.append + + angular = getattr(self.__rich_repr__, "angular", False) # type: ignore + for arg in self.__rich_repr__(): # type: ignore + if isinstance(arg, tuple): + if len(arg) == 1: + append(repr(arg[0])) else: - if len(default) and default[0] == value: - continue - append(f"{key}={value!r}") + key, value, *default = arg + if key is None: + append(repr(value)) + else: + if len(default) and default[0] == value: + continue + append(f"{key}={value!r}") + else: + append(repr(arg)) + if angular: + return f"<{self.__class__.__name__} {' '.join(repr_str)}>" else: - append(repr(arg)) - if angular: - return f"<{self.__class__.__name__} {' '.join(repr_str)}>" - else: - return f"{self.__class__.__name__}({', '.join(repr_str)})" - - auto_repr.__doc__ = "Return repr(self)" - cls.__repr__ = auto_repr # type: ignore - - return cls + return f"{self.__class__.__name__}({', '.join(repr_str)})" + + def auto_rich_repr(self: T) -> RichReprResult: + """Auto generate __rich_rep__ from signature of __init__""" + try: + signature = inspect.signature(self.__init__) # type: ignore + for name, param in signature.parameters.items(): + if param.kind == param.POSITIONAL_ONLY: + yield getattr(self, name) + elif param.kind in ( + param.POSITIONAL_OR_KEYWORD, + param.KEYWORD_ONLY, + ): + if param.default == param.empty: + yield getattr(self, param.name) + else: + yield param.name, getattr(self, param.name), param.default + except Exception as error: + raise ReprError( + f"Failed to auto generate __rich_repr__; {error}" + ) from None + + if not hasattr(cls, "__rich_repr__"): + auto_rich_repr.__doc__ = "Build a rich repr" + cls.__rich_repr__ = auto_rich_repr # type: ignore + cls.__rich_repr__.angular = angular # type: ignore + + auto_repr.__doc__ = "Return repr(self)" + cls.__repr__ = auto_repr # type: ignore + return cls + + if cls is None: + angular = angular + return do_replace + else: + return do_replace(cls) + + +rich_repr: Callable[[Type[T]], Type[T]] = auto if __name__ == "__main__": - @rich_repr + @auto class Foo: def __rich_repr__(self) -> RichReprResult: - yield "foo" yield "bar", {"shopping": ["eggs", "ham", "pineapple"]} yield "buy", "hand sanitizer" - __rich_repr__.angular = False # type: ignore - foo = Foo() from rich.console import Console - from rich import print console = Console() diff --git a/rich/segment.py b/rich/segment.py index 741d119ee..58a817599 100644 --- a/rich/segment.py +++ b/rich/segment.py @@ -425,16 +425,10 @@ def __rich_console__( self, console: "Console", options: "ConsoleOptions" ) -> "RenderResult": if self.new_lines: - segments = self.segments - splice_segments: list["Segment | None"] = [None] * (len(segments) * 2) - splice_segments[::2] = segments - splice_segments[1::2] = [Segment.line()] * len(segments) - - yield from cast("list[Segment]", splice_segments) - - # for segment in self.segments: - # yield segment - # yield line + line = Segment.line() + for segment in self.segments: + yield segment + yield line else: yield from self.segments diff --git a/rich/style.py b/rich/style.py index 0db055689..d92df0f68 100644 --- a/rich/style.py +++ b/rich/style.py @@ -1,14 +1,16 @@ import sys from functools import lru_cache -import marshal +from marshal import loads as marshal_loads, dumps as marshal_dumps from random import randint from time import time -from typing import Any, Dict, Iterable, List, Optional, Type, Union +from typing import Any, cast, Dict, Iterable, List, Optional, Type, Union from . import errors from .color import Color, ColorParseError, ColorSystem, blend_rgb +from .repr import rich_repr, RichReprResult from .terminal_theme import DEFAULT_TERMINAL_THEME, TerminalTheme + # Style instances and style definitions are often interchangeable StyleType = Union[str, "Style"] @@ -27,6 +29,7 @@ def __get__(self, obj: "Style", objtype: Type["Style"]) -> Optional[bool]: return None +@rich_repr class Style: """A terminal style. @@ -112,7 +115,7 @@ def __init__( encircle: Optional[bool] = None, overline: Optional[bool] = None, link: Optional[str] = None, - meta: Optional[Dict[str, str]] = None, + meta: Optional[Dict[str, Any]] = None, ): self._ansi: Optional[str] = None self._style_definition: Optional[str] = None @@ -163,7 +166,7 @@ def _make_color(color: Union[Color, str]) -> Color: self._link = link self._link_id = f"{time()}-{randint(0, 999999)}" if link else "" - self._meta = None if meta is None else marshal.dumps(meta) + self._meta = None if meta is None else marshal_dumps(meta) self._hash = hash( ( self._color, @@ -349,9 +352,24 @@ def pick_first(cls, *values: Optional[StyleType]) -> StyleType: return value raise ValueError("expected at least one non-None style") - def __repr__(self) -> str: - """Render a named style differently from an anonymous style.""" - return f'Style.parse("{self}")' + def __rich_repr__(self) -> RichReprResult: + yield "color", self.color, None + yield "bgcolor", self.bgcolor, None + yield "bold", self.bold, None, + yield "dim", self.dim, None, + yield "italic", self.italic, None + yield "underline", self.underline, None, + yield "blink", self.blink, None + yield "blink2", self.blink2, None + yield "reverse", self.reverse, None + yield "conceal", self.conceal, None + yield "strike", self.strike, None + yield "underline2", self.underline2, None + yield "frame", self.frame, None + yield "encircle", self.encircle, None + yield "link", self.link, None + if self._meta: + yield "meta", self.meta def __eq__(self, other: Any) -> bool: if not isinstance(other, Style): @@ -394,9 +412,13 @@ def background_style(self) -> "Style": return Style(bgcolor=self.bgcolor) @property - def meta(self) -> Dict[str, str]: + def meta(self) -> Dict[str, Any]: """Get meta information (can not be changed after construction).""" - return {} if self._meta is None else marshal.loads(self._meta) + return ( + {} + if self._meta is None + else cast(Dict[str, Any], marshal_loads(self._meta)) + ) @property def without_color(self) -> "Style": @@ -460,7 +482,7 @@ def parse(cls, style_definition: str) -> "Style": } color: Optional[str] = None bgcolor: Optional[str] = None - attributes: Dict[str, Optional[bool]] = {} + attributes: Dict[str, Optional[Any]] = {} link: Optional[str] = None words = iter(style_definition.split()) @@ -589,7 +611,7 @@ def copy(self) -> "Style": style._link_id = f"{time()}-{randint(0, 999999)}" if self._link else "" style._hash = self._hash style._null = False - self._meta = self._meta + style._meta = self._meta return style def update_link(self, link: Optional[str] = None) -> "Style": @@ -674,11 +696,9 @@ def __add__(self, style: Optional["Style"]) -> "Style": new_style._hash = style._hash new_style._null = self._null or style._null if self._meta and style._meta: - new_style._meta = marshal.dumps({**self.meta, **style.meta}) - elif self._meta or style._meta: - new_style._meta = self._meta or style._meta + new_style._meta = marshal_dumps({**self.meta, **style.meta}) else: - new_style._meta = None + new_style._meta = self._meta or style._meta return new_style diff --git a/rich/text.py b/rich/text.py index 405e3cea9..d86af945b 100644 --- a/rich/text.py +++ b/rich/text.py @@ -53,7 +53,10 @@ class Span(NamedTuple): """Style associated with the span.""" def __repr__(self) -> str: - return f"Span({self.start}, {self.end}, {str(self.style)!r})" + if isinstance(self.style, Style) and self.style._meta: + return f"Span({self.start}, {self.end}, {self.style!r})" + else: + return f"Span({self.start}, {self.end}, {str(self.style)!r})" def __bool__(self) -> bool: return self.end > self.start diff --git a/tests/test_color.py b/tests/test_color.py index 49f344e14..389c00ed3 100644 --- a/tests/test_color.py +++ b/tests/test_color.py @@ -14,11 +14,11 @@ def test_str() -> None: - assert str(Color.parse("red")) == "" + assert str(Color.parse("red")) == "Color('red', ColorType.STANDARD, number=1)" def test_repr() -> None: - assert repr(Color.parse("red")) == "" + assert repr(Color.parse("red")) == "Color('red', ColorType.STANDARD, number=1)" def test_rich() -> None: diff --git a/tests/test_console.py b/tests/test_console.py index 0dd992f28..80d21dca4 100644 --- a/tests/test_console.py +++ b/tests/test_console.py @@ -663,4 +663,4 @@ def test_size_properties(): console.width = 5 assert console.size == ConsoleDimensions(5, 20) console.height = 10 - assert console.size == ConsoleDimensions(5, 10) \ No newline at end of file + assert console.size == ConsoleDimensions(5, 10) diff --git a/tests/test_control.py b/tests/test_control.py index 1273732e1..dbd21fe11 100644 --- a/tests/test_control.py +++ b/tests/test_control.py @@ -44,4 +44,4 @@ def test_move_to_column(): "\x1b[11G\x1b[20A", None, [(ControlType.CURSOR_MOVE_TO_COLUMN, 10), (ControlType.CURSOR_UP, 20)], - ) \ No newline at end of file + ) diff --git a/tests/test_repr.py b/tests/test_repr.py index 704147f51..c41de9926 100644 --- a/tests/test_repr.py +++ b/tests/test_repr.py @@ -1,10 +1,11 @@ +import pytest from typing import Optional from rich.console import Console -from rich.repr import rich_repr +import rich.repr -@rich_repr +@rich.repr.auto class Foo: def __init__(self, foo: str, bar: Optional[int] = None, egg: int = 1): self.foo = foo @@ -18,7 +19,31 @@ def __rich_repr__(self): yield "egg", self.egg -@rich_repr +@rich.repr.auto +class Egg: + def __init__(self, foo: str, /, bar: Optional[int] = None, egg: int = 1): + self.foo = foo + self.bar = bar + self.egg = egg + + +@rich.repr.auto +class BrokenEgg: + def __init__(self, foo: str, *, bar: Optional[int] = None, egg: int = 1): + self.foo = foo + self.fubar = bar + self.egg = egg + + +@rich.repr.auto(angular=True) +class AngularEgg: + def __init__(self, foo: str, *, bar: Optional[int] = None, egg: int = 1): + self.foo = foo + self.bar = bar + self.egg = egg + + +@rich.repr.auto class Bar(Foo): def __rich_repr__(self): yield (self.foo,) @@ -39,6 +64,19 @@ def test_rich_angular() -> None: assert (repr(Bar("hello", bar=3))) == "" +def test_rich_repr_auto() -> None: + assert repr(Egg("hello", egg=2)) == "Egg('hello', egg=2)" + + +def test_rich_repr_auto_angular() -> None: + assert repr(AngularEgg("hello", egg=2)) == "" + + +def test_broken_egg() -> None: + with pytest.raises(rich.repr.ReprError): + repr(BrokenEgg("foo")) + + def test_rich_pretty() -> None: console = Console() with console.capture() as capture: diff --git a/tests/test_style.py b/tests/test_style.py index 676caf961..d64d3783b 100644 --- a/tests/test_style.py +++ b/tests/test_style.py @@ -58,7 +58,10 @@ def test_ansi_codes(): def test_repr(): - assert repr(Style(bold=True, color="red")) == 'Style.parse("bold red")' + assert ( + repr(Style(bold=True, color="red")) + == "Style(color=Color('red', ColorType.STANDARD, number=1), bold=True)" + ) def test_eq(): @@ -205,3 +208,14 @@ def test_without_color(): assert colorless_style.bold == True null_style = Style.null() assert null_style.without_color == null_style + + +def test_meta(): + style = Style(bold=True, meta={"foo": "bar"}) + assert style.meta["foo"] == "bar" + + style += Style(meta={"egg": "baz"}) + + assert style.meta == {"foo": "bar", "egg": "baz"} + + assert repr(style) == "Style(bold=True, meta={'foo': 'bar', 'egg': 'baz'})"