From 6ac54d06edb506e72b276a87aa630e22a89e0b3b Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 16 Jun 2021 09:04:16 +0100 Subject: [PATCH 1/5] fix for pretty printing classes --- rich/console.py | 5 +++-- rich/control.py | 6 +++--- rich/pretty.py | 3 ++- rich/segment.py | 2 +- tests/test_control.py | 2 +- 5 files changed, 10 insertions(+), 8 deletions(-) diff --git a/rich/console.py b/rich/console.py index 1d28ee1f2..2f33907a4 100644 --- a/rich/console.py +++ b/rich/console.py @@ -13,6 +13,7 @@ from itertools import islice from time import monotonic from types import FrameType, TracebackType +from inspect import isclass from typing import ( IO, TYPE_CHECKING, @@ -1185,9 +1186,9 @@ def render( # No space to render anything. This prevents potential recursion errors. return render_iterable: RenderResult - if hasattr(renderable, "__rich__"): + if hasattr(renderable, "__rich__") and not isclass(renderable): renderable = renderable.__rich__() # type: ignore - if hasattr(renderable, "__rich_console__"): + if hasattr(renderable, "__rich_console__") and not isclass(renderable): render_iterable = renderable.__rich_console__(self, _options) # type: ignore elif isinstance(renderable, str): text_renderable = self.render_str( diff --git a/rich/control.py b/rich/control.py index 0a13b8ac5..c1201b947 100644 --- a/rich/control.py +++ b/rich/control.py @@ -27,7 +27,7 @@ ControlType.CURSOR_DOWN: lambda param: f"\x1b[{param}B", ControlType.CURSOR_FORWARD: lambda param: f"\x1b[{param}C", ControlType.CURSOR_BACKWARD: lambda param: f"\x1b[{param}D", - ControlType.CURSOR_MOVE_TO_ROW: lambda param: f"\x1b[{param+1}G", + ControlType.CURSOR_MOVE_TO_COLUMN: lambda param: f"\x1b[{param+1}G", ControlType.ERASE_IN_LINE: lambda param: f"\x1b[{param}K", ControlType.CURSOR_MOVE_TO: lambda x, y: f"\x1b[{y+1};{x+1}H", } @@ -106,14 +106,14 @@ def move_to_row(cls, x: int, y: int = 0) -> "Control": return ( cls( - (ControlType.CURSOR_MOVE_TO_ROW, x + 1), + (ControlType.CURSOR_MOVE_TO_COLUMN, x), ( ControlType.CURSOR_DOWN if y > 0 else ControlType.CURSOR_UP, abs(y), ), ) if y - else cls((ControlType.CURSOR_MOVE_TO_ROW, x)) + else cls((ControlType.CURSOR_MOVE_TO_COLUMN, x)) ) @classmethod diff --git a/rich/pretty.py b/rich/pretty.py index 3e4cd2c0c..425821c15 100644 --- a/rich/pretty.py +++ b/rich/pretty.py @@ -4,6 +4,7 @@ from array import array from collections import Counter, defaultdict, deque, UserDict, UserList from dataclasses import dataclass, fields, is_dataclass +from inspect import isclass from itertools import islice from typing import ( DefaultDict, @@ -498,7 +499,7 @@ def iter_rich_args(rich_args: Any) -> Iterable[Union[Any, Tuple[str, Any]]]: else: yield arg - if hasattr(obj, "__rich_repr__"): + if hasattr(obj, "__rich_repr__") and not isclass(obj): angular = getattr(obj.__rich_repr__, "angular", False) args = list(iter_rich_args(obj.__rich_repr__())) class_name = obj.__class__.__name__ diff --git a/rich/segment.py b/rich/segment.py index 60989e592..7ae29be1f 100644 --- a/rich/segment.py +++ b/rich/segment.py @@ -29,7 +29,7 @@ class ControlType(IntEnum): CURSOR_DOWN = 10 CURSOR_FORWARD = 11 CURSOR_BACKWARD = 12 - CURSOR_MOVE_TO_ROW = 13 + CURSOR_MOVE_TO_COLUMN = 13 CURSOR_MOVE_TO = 14 ERASE_IN_LINE = 15 diff --git a/tests/test_control.py b/tests/test_control.py index d3ff2777c..4e9ef4732 100644 --- a/tests/test_control.py +++ b/tests/test_control.py @@ -37,5 +37,5 @@ def test_move_to_row(): assert Control.move_to_row(10, 20).segment == Segment( "\x1b[12G\x1b[20B", None, - [(ControlType.CURSOR_MOVE_TO_ROW, 11), (ControlType.CURSOR_DOWN, 20)], + [(ControlType.CURSOR_MOVE_TO_COLUMN, 11), (ControlType.CURSOR_DOWN, 20)], ) From 41cc6a6ff2b54c4885776dc87d8091f1a556a64f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 17 Jun 2021 22:31:08 +0100 Subject: [PATCH 2/5] added meta to Style --- CHANGELOG.md | 11 ++++++++++- rich/_ratio.py | 2 +- rich/align.py | 2 +- rich/box.py | 2 +- rich/console.py | 6 +++++- rich/control.py | 4 ++-- rich/live_render.py | 2 +- rich/pretty.py | 6 +++--- rich/segment.py | 18 ++++++++++++------ rich/style.py | 24 +++++++++++++++++++++++- tests/test_console.py | 11 +++++++++++ tests/test_control.py | 16 +++++++++++----- tests/test_layout.py | 3 +++ tests/test_table.py | 10 ++++++++++ 14 files changed, 94 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 405258c5b..3e43293b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ 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 + +### Added + +- Added Style.meta + +### Fixed + +- Fixed error pretty printing classes with special **rich_repr** method + ## [10.3.0] - 2021-06-09 ### Added @@ -21,7 +31,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [10.2.2] - 2021-05-19 - ### Fixed - Fixed status not rendering console markup https://github.com/willmcgugan/rich/issues/1244 diff --git a/rich/_ratio.py b/rich/_ratio.py index 8084d39f1..57c22c90c 100644 --- a/rich/_ratio.py +++ b/rich/_ratio.py @@ -6,7 +6,7 @@ if sys.version_info >= (3, 8): from typing import Protocol else: - from typing_extensions import Protocol + from typing_extensions import Protocol # pragma: no cover class Edge(Protocol): diff --git a/rich/align.py b/rich/align.py index 22e4c60f7..b1f9b3bbf 100644 --- a/rich/align.py +++ b/rich/align.py @@ -5,7 +5,7 @@ if sys.version_info >= (3, 8): from typing import Literal else: - from typing_extensions import Literal + from typing_extensions import Literal # pragma: no cover from .constrain import Constrain from .jupyter import JupyterMixin diff --git a/rich/box.py b/rich/box.py index d044f74ae..de79f0593 100644 --- a/rich/box.py +++ b/rich/box.py @@ -4,7 +4,7 @@ if sys.version_info >= (3, 8): from typing import Literal else: - from typing_extensions import Literal + from typing_extensions import Literal # pragma: no cover from ._loop import loop_last diff --git a/rich/console.py b/rich/console.py index 2f33907a4..89324d266 100644 --- a/rich/console.py +++ b/rich/console.py @@ -37,7 +37,11 @@ if sys.version_info >= (3, 8): from typing import Literal, Protocol, runtime_checkable else: - from typing_extensions import Literal, Protocol, runtime_checkable + from typing_extensions import ( + Literal, + Protocol, + runtime_checkable, + ) # pragma: no cover from . import errors, themes diff --git a/rich/control.py b/rich/control.py index c1201b947..c98d0d7d9 100644 --- a/rich/control.py +++ b/rich/control.py @@ -93,8 +93,8 @@ def get_codes() -> Iterable[ControlCode]: return control @classmethod - def move_to_row(cls, x: int, y: int = 0) -> "Control": - """Move to the given row, optionally add offset to column. + def move_to_column(cls, x: int, y: int = 0) -> "Control": + """Move to the given column, optionally add offset to row. Returns: x (int): absolute x (column) diff --git a/rich/live_render.py b/rich/live_render.py index 63b6c5cf6..ca5ad23b2 100644 --- a/rich/live_render.py +++ b/rich/live_render.py @@ -4,7 +4,7 @@ if sys.version_info >= (3, 8): from typing import Literal else: - from typing_extensions import Literal + from typing_extensions import Literal # pragma: no cover from ._loop import loop_last diff --git a/rich/pretty.py b/rich/pretty.py index 425821c15..a2748af3c 100644 --- a/rich/pretty.py +++ b/rich/pretty.py @@ -283,10 +283,10 @@ def is_expandable(obj: Any) -> bool: """Check if an object may be expanded by pretty print.""" return ( isinstance(obj, _CONTAINERS) - or (is_dataclass(obj) and not isinstance(obj, type)) - or hasattr(obj, "__rich_repr__") + or (is_dataclass(obj)) + or (hasattr(obj, "__rich_repr__")) or _is_attr_object(obj) - ) + ) and not isclass(obj) @dataclass diff --git a/rich/segment.py b/rich/segment.py index 7ae29be1f..741d119ee 100644 --- a/rich/segment.py +++ b/rich/segment.py @@ -7,7 +7,7 @@ from itertools import filterfalse from operator import attrgetter -from typing import Iterable, List, Sequence, Union, Tuple, TYPE_CHECKING +from typing import cast, Iterable, List, Sequence, Union, Tuple, TYPE_CHECKING if TYPE_CHECKING: @@ -417,7 +417,7 @@ class Segments: new_lines (bool, optional): Add new lines between segments. Defaults to False. """ - def __init__(self, segments: Iterable[Segment], new_lines: bool = False) -> None: + def __init__(self, segments: Sequence[Segment], new_lines: bool = False) -> None: self.segments = list(segments) self.new_lines = new_lines @@ -425,10 +425,16 @@ def __rich_console__( self, console: "Console", options: "ConsoleOptions" ) -> "RenderResult": if self.new_lines: - line = Segment.line() - for segment in self.segments: - yield segment - yield line + 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 else: yield from self.segments diff --git a/rich/style.py b/rich/style.py index 7f3d9a17b..0db055689 100644 --- a/rich/style.py +++ b/rich/style.py @@ -1,5 +1,6 @@ import sys from functools import lru_cache +import marshal from random import randint from time import time from typing import Any, Dict, Iterable, List, Optional, Type, Union @@ -59,6 +60,7 @@ class Style: _set_attributes: int _hash: int _null: bool + _meta: Optional[bytes] __slots__ = [ "_color", @@ -71,6 +73,7 @@ class Style: "_style_definition", "_hash", "_null", + "_meta", ] # maps bits on to SGR parameter @@ -109,6 +112,7 @@ def __init__( encircle: Optional[bool] = None, overline: Optional[bool] = None, link: Optional[str] = None, + meta: Optional[Dict[str, str]] = None, ): self._ansi: Optional[str] = None self._style_definition: Optional[str] = None @@ -159,6 +163,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._hash = hash( ( self._color, @@ -166,9 +171,10 @@ def _make_color(color: Union[Color, str]) -> Color: self._attributes, self._set_attributes, link, + self._meta, ) ) - self._null = not (self._set_attributes or color or bgcolor or link) + self._null = not (self._set_attributes or color or bgcolor or link or meta) @classmethod def null(cls) -> "Style": @@ -194,6 +200,7 @@ def from_color( style._attributes = 0 style._link = None style._link_id = "" + style._meta = None style._hash = hash( ( color, @@ -355,6 +362,7 @@ def __eq__(self, other: Any) -> bool: and self._set_attributes == other._set_attributes and self._attributes == other._attributes and self._link == other._link + and self._meta == other._meta ) def __hash__(self) -> int: @@ -385,6 +393,11 @@ def background_style(self) -> "Style": """A Style with background only.""" return Style(bgcolor=self.bgcolor) + @property + def meta(self) -> Dict[str, str]: + """Get meta information (can not be changed after construction).""" + return {} if self._meta is None else marshal.loads(self._meta) + @property def without_color(self) -> "Style": """Get a copy of the style with color removed.""" @@ -401,6 +414,7 @@ def without_color(self) -> "Style": style._link_id = f"{time()}-{randint(0, 999999)}" if self._link else "" style._hash = self._hash style._null = False + style._meta = None return style @classmethod @@ -575,6 +589,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 return style def update_link(self, link: Optional[str] = None) -> "Style": @@ -597,6 +612,7 @@ def update_link(self, link: Optional[str] = None) -> "Style": style._link_id = f"{time()}-{randint(0, 999999)}" if link else "" style._hash = self._hash style._null = False + style._meta = self._meta return style def render( @@ -657,6 +673,12 @@ def __add__(self, style: Optional["Style"]) -> "Style": new_style._link_id = style._link_id or self._link_id 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 + else: + new_style._meta = None return new_style diff --git a/tests/test_console.py b/tests/test_console.py index 1b5493574..0dd992f28 100644 --- a/tests/test_console.py +++ b/tests/test_console.py @@ -653,3 +653,14 @@ def test_print_width_zero(): with console.capture() as capture: console.print("Hello", width=0) assert capture.get() == "" + + +def test_size_properties(): + console = Console(width=80, height=25) + assert console.size == ConsoleDimensions(80, 25) + console.size = (10, 20) + assert console.size == ConsoleDimensions(10, 20) + console.width = 5 + assert console.size == ConsoleDimensions(5, 20) + console.height = 10 + assert console.size == ConsoleDimensions(5, 10) \ No newline at end of file diff --git a/tests/test_control.py b/tests/test_control.py index 4e9ef4732..1273732e1 100644 --- a/tests/test_control.py +++ b/tests/test_control.py @@ -32,10 +32,16 @@ def test_control_move(): ) -def test_move_to_row(): - print(repr(Control.move_to_row(10, 20).segment)) - assert Control.move_to_row(10, 20).segment == Segment( - "\x1b[12G\x1b[20B", +def test_move_to_column(): + print(repr(Control.move_to_column(10, 20).segment)) + assert Control.move_to_column(10, 20).segment == Segment( + "\x1b[11G\x1b[20B", None, - [(ControlType.CURSOR_MOVE_TO_COLUMN, 11), (ControlType.CURSOR_DOWN, 20)], + [(ControlType.CURSOR_MOVE_TO_COLUMN, 10), (ControlType.CURSOR_DOWN, 20)], ) + + assert Control.move_to_column(10, -20).segment == Segment( + "\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_layout.py b/tests/test_layout.py index 5eae0f735..21d30d27f 100644 --- a/tests/test_layout.py +++ b/tests/test_layout.py @@ -45,6 +45,9 @@ def test_render(): assert layout["root"].name == "root" assert layout["left"].name == "left" + + assert isinstance(layout.map, dict) + with pytest.raises(KeyError): top["asdasd"] diff --git a/tests/test_table.py b/tests/test_table.py index b2c76f87a..29826e118 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -7,6 +7,7 @@ from rich import errors from rich.console import Console from rich.measure import Measurement +from rich.style import Style from rich.table import Table, Column from rich.text import Text @@ -148,6 +149,15 @@ def test_no_columns(): assert output == "\n" +def test_get_row_style(): + console = Console() + table = Table() + table.add_row("foo") + table.add_row("bar", style="on red") + assert table.get_row_style(console, 0) == Style.parse("") + assert table.get_row_style(console, 1) == Style.parse("on red") + + if __name__ == "__main__": render = render_tables() print(render) From 3281af6f3b9cbc2ab3254fe742b4f86ec0783879 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 18 Jun 2021 20:24:05 +0100 Subject: [PATCH 3/5] auto repr --- CHANGELOG.md | 3 +- docs/source/pretty.rst | 71 ++++++++++++++++++----- examples/repr.py | 13 ++--- pyproject.toml | 2 +- rich/color.py | 21 ++++--- rich/markup.py | 40 +++++++++++-- rich/repr.py | 125 ++++++++++++++++++++++++++++++----------- rich/segment.py | 14 ++--- rich/style.py | 50 ++++++++++++----- rich/text.py | 5 +- tests/test_color.py | 4 +- tests/test_console.py | 2 +- tests/test_control.py | 2 +- tests/test_repr.py | 44 ++++++++++++++- tests/test_style.py | 16 +++++- 15 files changed, 310 insertions(+), 102 deletions(-) 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'})" From 56b4c53274118311833e51e607b3d62a8fbc464a Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 18 Jun 2021 20:27:13 +0100 Subject: [PATCH 4/5] auto import --- rich/markup.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/rich/markup.py b/rich/markup.py index 159b104cb..2a7d954b7 100644 --- a/rich/markup.py +++ b/rich/markup.py @@ -3,8 +3,6 @@ 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 From 6e97016e22c291e0fa0b20cb648de6d8cfc96ac7 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 18 Jun 2021 20:37:33 +0100 Subject: [PATCH 5/5] test fix --- tests/test_color.py | 4 ++++ tests/test_markup.py | 14 ++++++++++++++ tests/test_repr.py | 2 +- 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/tests/test_color.py b/tests/test_color.py index 389c00ed3..796f01983 100644 --- a/tests/test_color.py +++ b/tests/test_color.py @@ -21,6 +21,10 @@ def test_repr() -> None: assert repr(Color.parse("red")) == "Color('red', ColorType.STANDARD, number=1)" +def test_color_system_repr() -> None: + assert repr(ColorSystem.EIGHT_BIT) == "ColorSystem.EIGHT_BIT" + + def test_rich() -> None: color = Color.parse("red") as_text = color.__rich__() diff --git a/tests/test_markup.py b/tests/test_markup.py index 07c16425c..0f7061c4d 100644 --- a/tests/test_markup.py +++ b/tests/test_markup.py @@ -149,3 +149,17 @@ def test_escape_escape(): result = render(r"\\\\") assert str(result) == r"\\\\" + + +def test_events(): + + result = render("[@click]Hello[/@click] [@click='view.toggle', 'left']World[/]") + assert str(result) == "Hello World" + + +def test_events_broken(): + with pytest.raises(MarkupError): + render("[@click=sdfwer]foo[/]") + + with pytest.raises(MarkupError): + render("[@click='view.toggle]foo[/]") diff --git a/tests/test_repr.py b/tests/test_repr.py index c41de9926..c4f8bd09a 100644 --- a/tests/test_repr.py +++ b/tests/test_repr.py @@ -21,7 +21,7 @@ def __rich_repr__(self): @rich.repr.auto class Egg: - def __init__(self, foo: str, /, bar: Optional[int] = None, egg: int = 1): + def __init__(self, foo: str, bar: Optional[int] = None, egg: int = 1): self.foo = foo self.bar = bar self.egg = egg