diff options
author | morpheus65535 <[email protected]> | 2022-01-23 23:07:52 -0500 |
---|---|---|
committer | morpheus65535 <[email protected]> | 2022-01-23 23:07:52 -0500 |
commit | 0c3c5a02a75bc61b6bf6e303de20e11741d2afac (patch) | |
tree | 30ae1d524ffe5d54172b7a4a8445d90c3461e659 /libs/rich | |
parent | 36bf0d219d0432c20e6314e0ce752b36f4d88e3c (diff) | |
download | bazarr-0c3c5a02a75bc61b6bf6e303de20e11741d2afac.tar.gz bazarr-0c3c5a02a75bc61b6bf6e303de20e11741d2afac.zip |
Upgraded vendored Python dependencies to the latest versions and removed the unused dependencies.v1.0.3-beta.16
Diffstat (limited to 'libs/rich')
58 files changed, 2910 insertions, 1115 deletions
diff --git a/libs/rich/__init__.py b/libs/rich/__init__.py index b0e4c8d94..ed11f5d7e 100644 --- a/libs/rich/__init__.py +++ b/libs/rich/__init__.py @@ -1,7 +1,9 @@ """Rich text and beautiful formatting in the terminal.""" import os -from typing import Any, IO, Optional, TYPE_CHECKING +from typing import Callable, IO, TYPE_CHECKING, Any, Optional + +from ._extension import load_ipython_extension __all__ = ["get_console", "reconfigure", "print", "inspect"] @@ -30,8 +32,8 @@ def get_console() -> "Console": return _console -def reconfigure(*args, **kwargs) -> None: - """Reconfigures the global console bu replacing it with another. +def reconfigure(*args: Any, **kwargs: Any) -> None: + """Reconfigures the global console by replacing it with another. Args: console (Console): Replacement console instance. @@ -39,10 +41,17 @@ def reconfigure(*args, **kwargs) -> None: from rich.console import Console new_console = Console(*args, **kwargs) + _console = get_console() _console.__dict__ = new_console.__dict__ -def print(*objects: Any, sep=" ", end="\n", file: IO[str] = None, flush: bool = False): +def print( + *objects: Any, + sep: str = " ", + end: str = "\n", + file: Optional[IO[str]] = None, + flush: bool = False, +) -> None: r"""Print object(s) supplied via positional arguments. This function has an identical signature to the built-in print. For more advanced features, see the :class:`~rich.console.Console` class. @@ -60,11 +69,54 @@ def print(*objects: Any, sep=" ", end="\n", file: IO[str] = None, flush: bool = return write_console.print(*objects, sep=sep, end=end) +def print_json( + json: Optional[str] = None, + *, + data: Any = None, + indent: int = 2, + highlight: bool = True, + skip_keys: bool = False, + ensure_ascii: bool = True, + check_circular: bool = True, + allow_nan: bool = True, + default: Optional[Callable[[Any], Any]] = None, + sort_keys: bool = False, +) -> None: + """Pretty prints JSON. Output will be valid JSON. + + Args: + json (str): A string containing JSON. + data (Any): If json is not supplied, then encode this data. + indent (int, optional): Number of spaces to indent. Defaults to 2. + highlight (bool, optional): Enable highlighting of output: Defaults to True. + skip_keys (bool, optional): Skip keys not of a basic type. Defaults to False. + ensure_ascii (bool, optional): Escape all non-ascii characters. Defaults to False. + check_circular (bool, optional): Check for circular references. Defaults to True. + allow_nan (bool, optional): Allow NaN and Infinity values. Defaults to True. + default (Callable, optional): A callable that converts values that can not be encoded + in to something that can be JSON encoded. Defaults to None. + sort_keys (bool, optional): Sort dictionary keys. Defaults to False. + """ + + get_console().print_json( + json, + data=data, + indent=indent, + highlight=highlight, + skip_keys=skip_keys, + ensure_ascii=ensure_ascii, + check_circular=check_circular, + allow_nan=allow_nan, + default=default, + sort_keys=sort_keys, + ) + + def inspect( obj: Any, *, - console: "Console" = None, - title: str = None, + console: Optional["Console"] = None, + title: Optional[str] = None, help: bool = False, methods: bool = False, docs: bool = True, @@ -72,8 +124,8 @@ def inspect( dunder: bool = False, sort: bool = True, all: bool = False, - value: bool = True -): + value: bool = True, +) -> None: """Inspect any Python object. * inspect(<OBJECT>) to see summarized info. diff --git a/libs/rich/__main__.py b/libs/rich/__main__.py index 4744e54a8..132e80941 100644 --- a/libs/rich/__main__.py +++ b/libs/rich/__main__.py @@ -4,7 +4,7 @@ from time import process_time from rich import box from rich.color import Color -from rich.console import Console, ConsoleOptions, RenderGroup, RenderResult +from rich.console import Console, ConsoleOptions, Group, RenderableType, RenderResult from rich.markdown import Markdown from rich.measure import Measurement from rich.pretty import Pretty @@ -80,7 +80,7 @@ def make_test_card() -> Table: ) table.add_row( "Text", - RenderGroup( + Group( Text.from_markup( """Word wrap text. Justify [green]left[/], [yellow]center[/], [blue]right[/] or [red]full[/].\n""" ), @@ -88,7 +88,7 @@ def make_test_card() -> Table: ), ) - def comparison(renderable1, renderable2) -> Table: + def comparison(renderable1: RenderableType, renderable2: RenderableType) -> Table: table = Table(show_header=False, pad_edge=False, box=None, expand=True) table.add_column("1", ratio=1) table.add_column("2", ratio=1) @@ -101,7 +101,7 @@ def make_test_card() -> Table: ) markup_example = ( - "[bold magenta]Rich[/] supports a simple [i]bbcode[/i] like [b]markup[/b] for [yellow]color[/], [underline]style[/], and emoji! " + "[bold magenta]Rich[/] supports a simple [i]bbcode[/i]-like [b]markup[/b] for [yellow]color[/], [underline]style[/], and emoji! " ":+1: :apple: :ant: :bear: :baguette_bread: :bus: " ) table.add_row("Markup", markup_example) @@ -189,7 +189,7 @@ def iter_last(values: Iterable[T]) -> Iterable[Tuple[bool, T]]: markdown_example = """\ # Markdown -Supports much of the *markdown*, __syntax__! +Supports much of the *markdown* __syntax__! - Headers - Basic formatting: **bold**, *italic*, `code` @@ -216,7 +216,10 @@ if __name__ == "__main__": # pragma: no cover test_card = make_test_card() # Print once to warm cache + start = process_time() console.print(test_card) + pre_cache_taken = round((process_time() - start) * 1000.0, 1) + console.file = io.StringIO() start = process_time() @@ -225,10 +228,11 @@ if __name__ == "__main__": # pragma: no cover text = console.file.getvalue() # https://bugs.python.org/issue37871 - for line in text.splitlines(): - print(line) + for line in text.splitlines(True): + print(line, end="") - print(f"rendered in {taken}ms") + print(f"rendered in {pre_cache_taken}ms (cold cache)") + print(f"rendered in {taken}ms (warm cache)") from rich.panel import Panel @@ -237,13 +241,10 @@ if __name__ == "__main__": # pragma: no cover sponsor_message = Table.grid(padding=1) sponsor_message.add_column(style="green", justify="right") sponsor_message.add_column(no_wrap=True) + sponsor_message.add_row( - "Sponsor me", - "[u blue link=https://github.com/sponsors/willmcgugan]https://github.com/sponsors/willmcgugan", - ) - sponsor_message.add_row( - "Buy me a :coffee:", - "[u blue link=https://ko-fi.com/willmcgugan]https://ko-fi.com/willmcgugan", + "Buy devs a :coffee:", + "[u blue link=https://ko-fi.com/textualize]https://ko-fi.com/textualize", ) sponsor_message.add_row( "Twitter", @@ -255,9 +256,9 @@ if __name__ == "__main__": # pragma: no cover intro_message = Text.from_markup( """\ -It takes a lot of time to develop Rich and to provide support. +We hope you enjoy using Rich! -Consider supporting my work via Github Sponsors (ask your company / organization), or buy me a coffee to say thanks. +Rich is maintained with :heart: by [link=https://www.textualize.io]Textualize.io[/] - Will McGugan""" ) diff --git a/libs/rich/_emoji_replace.py b/libs/rich/_emoji_replace.py index ee8cde009..bb2cafa18 100644 --- a/libs/rich/_emoji_replace.py +++ b/libs/rich/_emoji_replace.py @@ -1,17 +1,32 @@ -from typing import Match - +from typing import Callable, Match, Optional import re from ._emoji_codes import EMOJI -def _emoji_replace(text: str, _emoji_sub=re.compile(r"(:(\S*?):)").sub) -> str: +_ReStringMatch = Match[str] # regex match object +_ReSubCallable = Callable[[_ReStringMatch], str] # Callable invoked by re.sub +_EmojiSubMethod = Callable[[_ReSubCallable, str], str] # Sub method of a compiled re + + +def _emoji_replace( + text: str, + default_variant: Optional[str] = None, + _emoji_sub: _EmojiSubMethod = re.compile(r"(:(\S*?)(?:(?:\-)(emoji|text))?:)").sub, +) -> str: """Replace emoji code in text.""" - get_emoji = EMOJI.get + get_emoji = EMOJI.__getitem__ + variants = {"text": "\uFE0E", "emoji": "\uFE0F"} + get_variant = variants.get + default_variant_code = variants.get(default_variant, "") if default_variant else "" def do_replace(match: Match[str]) -> str: - """Called by re.sub to do the replacement.""" - emoji_code, emoji_name = match.groups() - return get_emoji(emoji_name.lower(), emoji_code) + emoji_code, emoji_name, variant = match.groups() + try: + return get_emoji(emoji_name.lower()) + get_variant( + variant, default_variant_code + ) + except KeyError: + return emoji_code return _emoji_sub(do_replace, text) diff --git a/libs/rich/_extension.py b/libs/rich/_extension.py new file mode 100644 index 000000000..38658864e --- /dev/null +++ b/libs/rich/_extension.py @@ -0,0 +1,10 @@ +from typing import Any + + +def load_ipython_extension(ip: Any) -> None: # pragma: no cover + # prevent circular import + from rich.pretty import install + from rich.traceback import install as tr_install + + install() + tr_install() diff --git a/libs/rich/_inspect.py b/libs/rich/_inspect.py index 6e6ed8786..262695b1c 100644 --- a/libs/rich/_inspect.py +++ b/libs/rich/_inspect.py @@ -3,7 +3,7 @@ from __future__ import absolute_import from inspect import cleandoc, getdoc, getfile, isclass, ismodule, signature from typing import Any, Iterable, Optional, Tuple -from .console import RenderableType, RenderGroup +from .console import RenderableType, Group from .highlighter import ReprHighlighter from .jupyter import JupyterMixin from .panel import Panel @@ -44,7 +44,7 @@ class Inspect(JupyterMixin): self, obj: Any, *, - title: TextType = None, + title: Optional[TextType] = None, help: bool = False, methods: bool = False, docs: bool = True, @@ -79,7 +79,7 @@ class Inspect(JupyterMixin): def __rich__(self) -> Panel: return Panel.fit( - RenderGroup(*self._render()), + Group(*self._render()), title=self.title, border_style="scope.border", padding=(0, 1), @@ -149,14 +149,15 @@ class Inspect(JupyterMixin): yield signature yield "" - _doc = getdoc(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 = highlighter(doc_text) - yield doc_text - yield "" + if self.docs: + _doc = getdoc(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 = highlighter(doc_text) + yield doc_text + yield "" if self.value and not (isclass(obj) or callable(obj) or ismodule(obj)): yield Panel( @@ -204,8 +205,6 @@ class Inspect(JupyterMixin): if items_table.row_count: yield items_table else: - yield self.highlighter( - Text.from_markup( - f"[i][b]{not_shown_count}[/b] attribute(s) not shown.[/i] Run [b][red]inspect[/red]([not b]inspect[/])[/b] for options." - ) + yield Text.from_markup( + f"[b cyan]{not_shown_count}[/][i] attribute(s) not shown.[/i] Run [b][magenta]inspect[/]([not b]inspect[/])[/b] for options." ) diff --git a/libs/rich/_log_render.py b/libs/rich/_log_render.py index 67579633a..e8810100b 100644 --- a/libs/rich/_log_render.py +++ b/libs/rich/_log_render.py @@ -33,12 +33,12 @@ class LogRender: self, console: "Console", renderables: Iterable["ConsoleRenderable"], - log_time: datetime = None, - time_format: Union[str, FormatTimeCallable] = None, + log_time: Optional[datetime] = None, + time_format: Optional[Union[str, FormatTimeCallable]] = None, level: TextType = "", - path: str = None, - line_no: int = None, - link_path: str = None, + path: Optional[str] = None, + line_no: Optional[int] = None, + link_path: Optional[str] = None, ) -> "Table": from .containers import Renderables from .table import Table @@ -75,7 +75,11 @@ class LogRender: path, style=f"link file://{link_path}" if link_path else "" ) if line_no: - path_text.append(f":{line_no}") + path_text.append(":") + path_text.append( + f"{line_no}", + style=f"link file://{link_path}#{line_no}" if link_path else "", + ) row.append(path_text) output.add_row(*row) diff --git a/libs/rich/_lru_cache.py b/libs/rich/_lru_cache.py index b77c337ca..b7bf2ce1a 100644 --- a/libs/rich/_lru_cache.py +++ b/libs/rich/_lru_cache.py @@ -6,7 +6,7 @@ CacheKey = TypeVar("CacheKey") CacheValue = TypeVar("CacheValue") -class LRUCache(Generic[CacheKey, CacheValue], OrderedDict): +class LRUCache(Generic[CacheKey, CacheValue], OrderedDict): # type: ignore # https://github.com/python/mypy/issues/6904 """ A dictionary-like container that stores a given maximum items. diff --git a/libs/rich/_ratio.py b/libs/rich/_ratio.py index 6de54bc9e..f7dbe9270 100644 --- a/libs/rich/_ratio.py +++ b/libs/rich/_ratio.py @@ -1,7 +1,12 @@ +import sys from fractions import Fraction -from math import ceil, floor, modf +from math import ceil from typing import cast, List, Optional, Sequence -from typing_extensions import Protocol + +if sys.version_info >= (3, 8): + from typing import Protocol +else: + from typing_extensions import Protocol # pragma: no cover class Edge(Protocol): @@ -106,7 +111,7 @@ def ratio_reduce( def ratio_distribute( - total: int, ratios: List[int], minimums: List[int] = None + total: int, ratios: List[int], minimums: Optional[List[int]] = None ) -> List[int]: """Distribute an integer total in to parts based on ratios. @@ -141,7 +146,7 @@ def ratio_distribute( return distributed_total -if __name__ == "__main__": # type: ignore +if __name__ == "__main__": from dataclasses import dataclass @dataclass diff --git a/libs/rich/_timer.py b/libs/rich/_timer.py index b30d37490..a2ca6be03 100644 --- a/libs/rich/_timer.py +++ b/libs/rich/_timer.py @@ -6,10 +6,11 @@ Timer context manager, only used in debug. from time import time import contextlib +from typing import Generator @contextlib.contextmanager -def timer(subject: str = "time"): +def timer(subject: str = "time") -> Generator[None, None, None]: """print the elapsed time. (only used in debugging)""" start = time() yield diff --git a/libs/rich/_windows.py b/libs/rich/_windows.py index d252d1f9e..b1b30b65e 100644 --- a/libs/rich/_windows.py +++ b/libs/rich/_windows.py @@ -1,5 +1,4 @@ import sys - from dataclasses import dataclass @@ -15,10 +14,13 @@ class WindowsConsoleFeatures: try: import ctypes - from ctypes import wintypes - from ctypes import LibraryLoader + from ctypes import LibraryLoader, wintypes - windll = LibraryLoader(ctypes.WinDLL) # type: ignore + if sys.platform == "win32": + windll = LibraryLoader(ctypes.WinDLL) + else: + windll = None + raise ImportError("Not windows") except (AttributeError, ImportError, ValueError): # Fallback if we can't load the Windows DLL @@ -26,7 +28,6 @@ except (AttributeError, ImportError, ValueError): features = WindowsConsoleFeatures() return features - else: STDOUT = -11 @@ -53,7 +54,7 @@ else: vt = bool(result and console_mode.value & ENABLE_VIRTUAL_TERMINAL_PROCESSING) truecolor = False if vt: - win_version = sys.getwindowsversion() # type: ignore + win_version = sys.getwindowsversion() truecolor = win_version.major > 10 or ( win_version.major == 10 and win_version.build >= 15063 ) diff --git a/libs/rich/align.py b/libs/rich/align.py index 83bf5d697..3f605a7b9 100644 --- a/libs/rich/align.py +++ b/libs/rich/align.py @@ -1,16 +1,20 @@ +import sys from itertools import chain -from typing import Iterable, TYPE_CHECKING +from typing import TYPE_CHECKING, Iterable, Optional + +if sys.version_info >= (3, 8): + from typing import Literal +else: + from typing_extensions import Literal # pragma: no cover -from typing_extensions import Literal from .constrain import Constrain from .jupyter import JupyterMixin from .measure import Measurement from .segment import Segment from .style import StyleType - if TYPE_CHECKING: - from .console import Console, ConsoleOptions, RenderResult, RenderableType + from .console import Console, ConsoleOptions, RenderableType, RenderResult AlignMethod = Literal["left", "center", "right"] VerticalAlignMethod = Literal["top", "middle", "bottom"] @@ -37,12 +41,12 @@ class Align(JupyterMixin): self, renderable: "RenderableType", align: AlignMethod = "left", - style: StyleType = None, + style: Optional[StyleType] = None, *, - vertical: VerticalAlignMethod = None, + vertical: Optional[VerticalAlignMethod] = None, pad: bool = True, - width: int = None, - height: int = None, + width: Optional[int] = None, + height: Optional[int] = None, ) -> None: if align not in ("left", "center", "right"): raise ValueError( @@ -67,12 +71,12 @@ class Align(JupyterMixin): def left( cls, renderable: "RenderableType", - style: StyleType = None, + style: Optional[StyleType] = None, *, - vertical: VerticalAlignMethod = None, + vertical: Optional[VerticalAlignMethod] = None, pad: bool = True, - width: int = None, - height: int = None, + width: Optional[int] = None, + height: Optional[int] = None, ) -> "Align": """Align a renderable to the left.""" return cls( @@ -89,12 +93,12 @@ class Align(JupyterMixin): def center( cls, renderable: "RenderableType", - style: StyleType = None, + style: Optional[StyleType] = None, *, - vertical: VerticalAlignMethod = None, + vertical: Optional[VerticalAlignMethod] = None, pad: bool = True, - width: int = None, - height: int = None, + width: Optional[int] = None, + height: Optional[int] = None, ) -> "Align": """Align a renderable to the center.""" return cls( @@ -111,12 +115,12 @@ class Align(JupyterMixin): def right( cls, renderable: "RenderableType", - style: StyleType = None, + style: Optional[StyleType] = None, *, - vertical: VerticalAlignMethod = None, + vertical: Optional[VerticalAlignMethod] = None, pad: bool = True, - width: int = None, - height: int = None, + width: Optional[int] = None, + height: Optional[int] = None, ) -> "Align": """Align a renderable to the right.""" return cls( @@ -133,7 +137,7 @@ class Align(JupyterMixin): self, console: "Console", options: "ConsoleOptions" ) -> "RenderResult": align = self.align - width = Measurement.get(console, options, self.renderable).maximum + width = console.measure(self.renderable, options=options).maximum rendered = console.render( Constrain( self.renderable, width if self.width is None else min(width, self.width) @@ -192,7 +196,7 @@ class Align(JupyterMixin): else Segment("\n") ) - def blank_lines(count) -> Iterable[Segment]: + def blank_lines(count: int) -> Iterable[Segment]: if count > 0: for _ in range(count): yield blank_line @@ -242,7 +246,7 @@ class VerticalCenter(JupyterMixin): def __init__( self, renderable: "RenderableType", - style: StyleType = None, + style: Optional[StyleType] = None, ) -> None: self.renderable = renderable self.style = style @@ -264,7 +268,7 @@ class VerticalCenter(JupyterMixin): bottom_space = height - top_space - len(lines) blank_line = Segment(f"{' ' * width}", style) - def blank_lines(count) -> Iterable[Segment]: + def blank_lines(count: int) -> Iterable[Segment]: for _ in range(count): yield blank_line yield new_line @@ -285,7 +289,7 @@ class VerticalCenter(JupyterMixin): if __name__ == "__main__": # pragma: no cover - from rich.console import Console, RenderGroup + from rich.console import Console, Group from rich.highlighter import ReprHighlighter from rich.panel import Panel @@ -293,7 +297,7 @@ if __name__ == "__main__": # pragma: no cover console = Console() panel = Panel( - RenderGroup( + Group( Align.left(highlighter("align='left'")), Align.center(highlighter("align='center'")), Align.right(highlighter("align='right'")), diff --git a/libs/rich/ansi.py b/libs/rich/ansi.py index 85410de5e..92e4772ed 100644 --- a/libs/rich/ansi.py +++ b/libs/rich/ansi.py @@ -208,7 +208,7 @@ if __name__ == "__main__": # pragma: no cover stdout = io.BytesIO() - def read(fd): + def read(fd: int) -> bytes: data = os.read(fd, 1024) stdout.write(data) return data diff --git a/libs/rich/bar.py b/libs/rich/bar.py index ecd18743e..ed86a552d 100644 --- a/libs/rich/bar.py +++ b/libs/rich/bar.py @@ -1,4 +1,4 @@ -from typing import Union +from typing import Optional, Union from .color import Color from .console import Console, ConsoleOptions, RenderResult @@ -32,7 +32,7 @@ class Bar(JupyterMixin): begin: float, end: float, *, - width: int = None, + width: Optional[int] = None, color: Union[Color, str] = "default", bgcolor: Union[Color, str] = "default", ): diff --git a/libs/rich/box.py b/libs/rich/box.py index b70b5c2cd..d37c6c81c 100644 --- a/libs/rich/box.py +++ b/libs/rich/box.py @@ -1,6 +1,11 @@ +import sys from typing import TYPE_CHECKING, Iterable, List -from typing_extensions import Literal +if sys.version_info >= (3, 8): + from typing import Literal +else: + from typing_extensions import Literal # pragma: no cover + from ._loop import loop_last @@ -430,7 +435,7 @@ if __name__ == "__main__": # pragma: no cover from rich.columns import Columns from rich.panel import Panel - from . import box + from . import box as box from .console import Console from .table import Table from .text import Text diff --git a/libs/rich/cells.py b/libs/rich/cells.py index 1a0ebccec..e824ea2a6 100644 --- a/libs/rich/cells.py +++ b/libs/rich/cells.py @@ -1,9 +1,13 @@ from functools import lru_cache +import re from typing import Dict, List from ._cell_widths import CELL_WIDTHS from ._lru_cache import LRUCache +# Regex to match sequence of the most common character ranges +_is_single_cell_widths = re.compile("^[\u0020-\u006f\u00a0\u02ff\u0370-\u0482]*$").match + def cell_len(text: str, _cache: Dict[str, int] = LRUCache(1024 * 4)) -> int: """Get the number of cells required to display text. @@ -12,19 +16,23 @@ def cell_len(text: str, _cache: Dict[str, int] = LRUCache(1024 * 4)) -> int: text (str): Text to display. Returns: - int: Number of cells required to display the text. + int: Get the number of cells required to display text. """ - cached_result = _cache.get(text, None) - if cached_result is not None: - return cached_result - - _get_size = get_character_cell_size - total_size = sum(_get_size(character) for character in text) - if len(text) <= 64: - _cache[text] = total_size + + if _is_single_cell_widths(text): + return len(text) + else: + cached_result = _cache.get(text, None) + if cached_result is not None: + return cached_result + _get_size = get_character_cell_size + total_size = sum(_get_size(character) for character in text) + if len(text) <= 64: + _cache[text] = total_size return total_size +@lru_cache(maxsize=4096) def get_character_cell_size(character: str) -> int: """Get the cell size of a character. @@ -34,12 +42,10 @@ def get_character_cell_size(character: str) -> int: Returns: int: Number of cells (0, 1 or 2) occupied by that character. """ - - codepoint = ord(character) - if 127 > codepoint > 31: - # Shortcut for ascii + if _is_single_cell_widths(character): return 1 - return _get_codepoint_cell_size(codepoint) + + return _get_codepoint_cell_size(ord(character)) @lru_cache(maxsize=4096) @@ -73,24 +79,41 @@ def _get_codepoint_cell_size(codepoint: int) -> int: def set_cell_size(text: str, total: int) -> str: """Set the length of a string to fit within given number of cells.""" + + if _is_single_cell_widths(text): + size = len(text) + if size < total: + return text + " " * (total - size) + return text[:total] + + if not total: + return "" cell_size = cell_len(text) if cell_size == total: return text if cell_size < total: return text + " " * (total - cell_size) - _get_character_cell_size = get_character_cell_size - character_sizes = [_get_character_cell_size(character) for character in text] - excess = cell_size - total - pop = character_sizes.pop - while excess > 0 and character_sizes: - excess -= pop() - text = text[: len(character_sizes)] - if excess == -1: - text += " " - return text + start = 0 + end = len(text) + + # Binary search until we find the right size + while True: + pos = (start + end) // 2 + before = text[: pos + 1] + before_len = cell_len(before) + if before_len == total + 1 and cell_len(before[-1]) == 2: + return before[:-1] + " " + if before_len == total: + return before + if before_len > total: + end = pos + else: + start = pos +# TODO: This is inefficient +# TODO: This might not work with CWJ type characters def chop_cells(text: str, max_size: int, position: int = 0) -> List[str]: """Break text in to equal (cell) length strings.""" _get_character_cell_size = get_character_cell_size diff --git a/libs/rich/color.py b/libs/rich/color.py index 3421061cc..f0fa026d6 100644 --- a/libs/rich/color.py +++ b/libs/rich/color.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, NamedTuple, Optional, Tuple from ._palettes import EIGHT_BIT_PALETTE, STANDARD_PALETTE, WINDOWS_PALETTE from .color_triplet import ColorTriplet +from .repr import rich_repr, Result 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 @@ rgb\(([\d\s,]+)\)$ ) +@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"<color {self.name!r} ({self.type.name.lower()})>" - if self.number is None - else f"<color {self.name!r} {self.number} ({self.type.name.lower()})>" - ) - def __rich__(self) -> "Text": """Dispays the actual color if Rich printed.""" from .text import Text @@ -287,6 +288,12 @@ class Color(NamedTuple): " >", ) + def __rich_repr__(self) -> Result: + 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.""" @@ -305,7 +312,7 @@ class Color(NamedTuple): return self.type == ColorType.DEFAULT def get_truecolor( - self, theme: "TerminalTheme" = None, foreground=True + self, theme: Optional["TerminalTheme"] = None, foreground: bool = True ) -> ColorTriplet: """Get an equivalent color triplet for this color. @@ -471,7 +478,7 @@ class Color(NamedTuple): def downgrade(self, system: ColorSystem) -> "Color": """Downgrade a color system to a system with fewer colors.""" - if self.type == ColorType.DEFAULT or self.type == system: + if self.type in [ColorType.DEFAULT, system]: return self # Convert to 8-bit color from truecolor color if system == ColorSystem.EIGHT_BIT and self.system == ColorSystem.TRUECOLOR: @@ -550,7 +557,6 @@ if __name__ == "__main__": # pragma: no cover from .console import Console from .table import Table from .text import Text - from . import box console = Console() diff --git a/libs/rich/color_triplet.py b/libs/rich/color_triplet.py index 75c03d2ac..02cab3282 100644 --- a/libs/rich/color_triplet.py +++ b/libs/rich/color_triplet.py @@ -29,7 +29,7 @@ class ColorTriplet(NamedTuple): @property def normalized(self) -> Tuple[float, float, float]: - """Covert components in to floats between 0 and 1. + """Convert components into floats between 0 and 1. Returns: Tuple[float, float, float]: A tuple of three normalized colour components. diff --git a/libs/rich/columns.py b/libs/rich/columns.py index 9746ecb71..669a3a707 100644 --- a/libs/rich/columns.py +++ b/libs/rich/columns.py @@ -30,16 +30,16 @@ class Columns(JupyterMixin): def __init__( self, - renderables: Iterable[RenderableType] = None, + renderables: Optional[Iterable[RenderableType]] = None, padding: PaddingDimensions = (0, 1), *, - width: int = None, + width: Optional[int] = None, expand: bool = False, equal: bool = False, column_first: bool = False, right_to_left: bool = False, - align: AlignMethod = None, - title: TextType = None, + align: Optional[AlignMethod] = None, + title: Optional[TextType] = None, ) -> None: self.renderables = list(renderables or []) self.width = width @@ -48,7 +48,7 @@ class Columns(JupyterMixin): self.equal = equal self.column_first = column_first self.right_to_left = right_to_left - self.align = align + self.align: Optional[AlignMethod] = align self.title = title def add_renderable(self, renderable: RenderableType) -> None: @@ -176,8 +176,6 @@ if __name__ == "__main__": # pragma: no cover console = Console() - from rich.panel import Panel - files = [f"{i} {s}" for i, s in enumerate(sorted(os.listdir()))] columns = Columns(files, padding=(0, 1), expand=False, equal=False) console.print(columns) diff --git a/libs/rich/console.py b/libs/rich/console.py index 71fa0809e..fbf87bd5c 100644 --- a/libs/rich/console.py +++ b/libs/rich/console.py @@ -1,17 +1,19 @@ import inspect import os import platform -import shutil import sys import threading from abc import ABC, abstractmethod -from collections import abc from dataclasses import dataclass, field from datetime import datetime from functools import wraps from getpass import getpass +from html import escape +from inspect import isclass from itertools import islice +from threading import RLock from time import monotonic +from types import FrameType, ModuleType, TracebackType from typing import ( IO, TYPE_CHECKING, @@ -24,11 +26,20 @@ from typing import ( NamedTuple, Optional, TextIO, + Tuple, + Type, Union, cast, ) -from typing_extensions import Literal, Protocol, runtime_checkable +if sys.version_info >= (3, 8): + from typing import Literal, Protocol, runtime_checkable +else: + from typing_extensions import ( + Literal, + Protocol, + runtime_checkable, + ) # pragma: no cover from . import errors, themes from ._emoji_replace import _emoji_replace @@ -36,11 +47,13 @@ from ._log_render import FormatTimeCallable, LogRender from .align import Align, AlignMethod from .color import ColorSystem from .control import Control +from .emoji import EmojiVariant from .highlighter import NullHighlighter, ReprHighlighter from .markup import render as render_markup from .measure import Measurement, measure_renderables from .pager import Pager, SystemPager -from .pretty import is_expandable, Pretty +from .pretty import Pretty, is_expandable +from .protocol import rich_cast from .region import Region from .scope import render_scope from .screen import Screen @@ -119,6 +132,8 @@ class ConsoleOptions: """True if the target is a terminal, otherwise False.""" encoding: str """Encoding of terminal.""" + max_height: int + """Height of container (starts as terminal)""" justify: Optional[JustifyMethod] = None """Justify value override for renderable.""" overflow: Optional[OverflowMethod] = None @@ -130,7 +145,6 @@ class ConsoleOptions: markup: Optional[bool] = None """Enable markup when rendering strings.""" height: Optional[int] = None - """Height available, or None for no height limit.""" @property def ascii_only(self) -> bool: @@ -143,7 +157,7 @@ class ConsoleOptions: Returns: ConsoleOptions: a copy of self. """ - options = ConsoleOptions.__new__(ConsoleOptions) + options: ConsoleOptions = ConsoleOptions.__new__(ConsoleOptions) options.__dict__ = self.__dict__.copy() return options @@ -179,6 +193,8 @@ class ConsoleOptions: if not isinstance(markup, NoChange): options.markup = markup if not isinstance(height, NoChange): + if height is not None: + options.max_height = height options.height = None if height is None else max(0, height) return options @@ -195,6 +211,19 @@ class ConsoleOptions: options.min_width = options.max_width = max(0, width) return options + def update_height(self, height: int) -> "ConsoleOptions": + """Update the height, and return a copy. + + Args: + height (int): New height + + Returns: + ~ConsoleOptions: New Console options instance. + """ + options = self.copy() + options.max_height = options.height = height + return options + def update_dimensions(self, width: int, height: int) -> "ConsoleOptions": """Update the width and height, and return a copy. @@ -207,7 +236,7 @@ class ConsoleOptions: """ options = self.copy() options.min_width = options.max_width = max(0, width) - options.height = height + options.height = options.max_height = height return options @@ -229,11 +258,12 @@ class ConsoleRenderable(Protocol): ... +# A type that may be rendered by Console. RenderableType = Union[ConsoleRenderable, RichCast, str] -"""A type that may be rendered by Console.""" + +# The result of calling a __rich_console__ method. RenderResult = Iterable[Union[RenderableType, Segment]] -"""The result of calling a __rich_console__ method.""" _null_highlighter = NullHighlighter() @@ -289,7 +319,12 @@ class Capture: self._console.begin_capture() return self - def __exit__(self, exc_type, exc_val, exc_tb) -> None: + def __exit__( + self, + exc_type: Optional[Type[BaseException]], + exc_val: Optional[BaseException], + exc_tb: Optional[TracebackType], + ) -> None: self._result = self._console.end_capture() def get(self) -> str: @@ -313,7 +348,12 @@ class ThemeContext: self.console.push_theme(self.theme) return self - def __exit__(self, exc_type, exc_val, exc_tb) -> None: + def __exit__( + self, + exc_type: Optional[Type[BaseException]], + exc_val: Optional[BaseException], + exc_tb: Optional[TracebackType], + ) -> None: self.console.pop_theme() @@ -323,7 +363,7 @@ class PagerContext: def __init__( self, console: "Console", - pager: Pager = None, + pager: Optional[Pager] = None, styles: bool = False, links: bool = False, ) -> None: @@ -336,7 +376,12 @@ class PagerContext: self._console._enter_buffer() return self - def __exit__(self, exc_type, exc_val, exc_tb) -> None: + def __exit__( + self, + exc_type: Optional[Type[BaseException]], + exc_val: Optional[BaseException], + exc_tb: Optional[TracebackType], + ) -> None: if exc_type is None: with self._console._lock: buffer: List[Segment] = self._console._buffer[:] @@ -362,7 +407,9 @@ class ScreenContext: self.screen = Screen(style=style) self._changed = False - def update(self, *renderables: RenderableType, style: StyleType = None) -> None: + def update( + self, *renderables: RenderableType, style: Optional[StyleType] = None + ) -> None: """Update the screen. Args: @@ -372,7 +419,7 @@ class ScreenContext: """ if renderables: self.screen.renderable = ( - RenderGroup(*renderables) if len(renderables) > 1 else renderables[0] + Group(*renderables) if len(renderables) > 1 else renderables[0] ) if style is not None: self.screen.style = style @@ -384,14 +431,19 @@ class ScreenContext: self.console.show_cursor(False) return self - def __exit__(self, exc_type, exc_val, exc_tb) -> None: + def __exit__( + self, + exc_type: Optional[Type[BaseException]], + exc_val: Optional[BaseException], + exc_tb: Optional[TracebackType], + ) -> None: if self._changed: self.console.set_alt_screen(False) if self.hide_cursor: self.console.show_cursor(True) -class RenderGroup: +class Group: """Takes a group of renderables and returns a renderable object that renders the group. Args: @@ -424,20 +476,22 @@ class RenderGroup: yield from self.renderables -def render_group(fit: bool = True) -> Callable: +def group(fit: bool = True) -> Callable[..., Callable[..., Group]]: """A decorator that turns an iterable of renderables in to a group. Args: fit (bool, optional): Fit dimension of group to contents, or fill available space. Defaults to True. """ - def decorator(method): - """Convert a method that returns an iterable of renderables in to a RenderGroup.""" + def decorator( + method: Callable[..., Iterable[RenderableType]] + ) -> Callable[..., Group]: + """Convert a method that returns an iterable of renderables in to a Group.""" @wraps(method) - def _replace(*args, **kwargs): + def _replace(*args: Any, **kwargs: Any) -> Group: renderables = method(*args, **kwargs) - return RenderGroup(*renderables, fit=fit) + return Group(*renderables, fit=fit) return _replace @@ -451,7 +505,7 @@ def _is_jupyter() -> bool: # pragma: no cover except NameError: return False ipython = get_ipython() # type: ignore - shell = ipython.__class__.__name__ # type: ignore + shell = ipython.__class__.__name__ if "google.colab" in str(ipython.__class__) or shell == "ZMQInteractiveShell": return True # Jupyter notebook or qtconsole elif shell == "TerminalInteractiveShell": @@ -520,7 +574,7 @@ def detect_legacy_windows() -> bool: if detect_legacy_windows(): # pragma: no cover from colorama import init - init() + init(strip=False) class Console: @@ -546,6 +600,7 @@ class Console: required to call :meth:`export_html` and :meth:`export_text`. Defaults to False. markup (bool, optional): Boolean to enable :ref:`console_markup`. Defaults to True. emoji (bool, optional): Enable emoji code. Defaults to True. + emoji_variant (str, optional): Optional emoji variant, either "text" or "emoji". Defaults to None. highlight (bool, optional): Enable automatic highlighting. Defaults to True. log_time (bool, optional): Boolean to enable logging of time by :meth:`log` methods. Defaults to True. log_path (bool, optional): Boolean to enable the logging of the caller by :meth:`log`. Defaults to True. @@ -566,32 +621,33 @@ class Console: color_system: Optional[ Literal["auto", "standard", "256", "truecolor", "windows"] ] = "auto", - force_terminal: bool = None, - force_jupyter: bool = None, - force_interactive: bool = None, + force_terminal: Optional[bool] = None, + force_jupyter: Optional[bool] = None, + force_interactive: Optional[bool] = None, soft_wrap: bool = False, - theme: Theme = None, + theme: Optional[Theme] = None, stderr: bool = False, - file: IO[str] = None, + file: Optional[IO[str]] = None, quiet: bool = False, - width: int = None, - height: int = None, - style: StyleType = None, - no_color: bool = None, + width: Optional[int] = None, + height: Optional[int] = None, + style: Optional[StyleType] = None, + no_color: Optional[bool] = None, tab_size: int = 8, record: bool = False, markup: bool = True, emoji: bool = True, + emoji_variant: Optional[EmojiVariant] = None, highlight: bool = True, log_time: bool = True, log_path: bool = True, log_time_format: Union[str, FormatTimeCallable] = "[%X]", highlighter: Optional["HighlighterType"] = ReprHighlighter(), - legacy_windows: bool = None, + legacy_windows: Optional[bool] = None, safe_box: bool = True, - get_datetime: Callable[[], datetime] = None, - get_time: Callable[[], float] = None, - _environ: Mapping[str, str] = None, + get_datetime: Optional[Callable[[], datetime]] = None, + get_time: Optional[Callable[[], float]] = None, + _environ: Optional[Mapping[str, str]] = None, ): # Copy of os.environ allows us to replace it for testing if _environ is not None: @@ -602,15 +658,6 @@ class Console: width = width or 93 height = height or 100 - if width is None: - columns = self._environ.get("COLUMNS") - if columns is not None and columns.isdigit(): - width = int(columns) - if height is None: - lines = self._environ.get("LINES") - if lines is not None and lines.isdigit(): - height = int(lines) - self.soft_wrap = soft_wrap self._width = width self._height = height @@ -618,12 +665,25 @@ class Console: self.record = record self._markup = markup self._emoji = emoji + self._emoji_variant: Optional[EmojiVariant] = emoji_variant self._highlight = highlight self.legacy_windows: bool = ( (detect_legacy_windows() and not self.is_jupyter) if legacy_windows is None else legacy_windows ) + if width is None: + columns = self._environ.get("COLUMNS") + if columns is not None and columns.isdigit(): + width = int(columns) - self.legacy_windows + if height is None: + lines = self._environ.get("LINES") + if lines is not None and lines.isdigit(): + height = int(lines) + + self.soft_wrap = soft_wrap + self._width = width + self._height = height self._color_system: Optional[ColorSystem] self._force_terminal = force_terminal @@ -721,7 +781,7 @@ class Console: if color_term in ("truecolor", "24bit"): return ColorSystem.TRUECOLOR term = self._environ.get("TERM", "").strip().lower() - _term_name, _hyphen, colors = term.partition("-") + _term_name, _hyphen, colors = term.rpartition("-") color_system = _TERM_COLORS.get(colors, ColorSystem.STANDARD) return color_system @@ -759,19 +819,20 @@ class Console: Args: hook (RenderHook): Render hook instance. """ - - self._render_hooks.append(hook) + with self._lock: + self._render_hooks.append(hook) def pop_render_hook(self) -> None: """Pop the last renderhook from the stack.""" - self._render_hooks.pop() + with self._lock: + self._render_hooks.pop() def __enter__(self) -> "Console": """Own context manager to enter buffer context.""" self._enter_buffer() return self - def __exit__(self, exc_type, exc_value, traceback) -> None: + def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None: """Exit buffer context.""" self._exit_buffer() @@ -849,8 +910,14 @@ class Console: """ if self._force_terminal is not None: return self._force_terminal - isatty = getattr(self.file, "isatty", None) - return False if isatty is None else isatty() + isatty: Optional[Callable[[], bool]] = getattr(self.file, "isatty", None) + try: + return False if isatty is None else isatty() + except ValueError: + # in some situation (at the end of a pytest run for example) isatty() can raise + # ValueError: I/O operation on closed file + # return False because we aren't in a terminal anymore + return False @property def is_dumb_terminal(self) -> bool: @@ -868,6 +935,7 @@ class Console: def options(self) -> ConsoleOptions: """Get default console options.""" return ConsoleOptions( + max_height=self.size.height, size=self.size, legacy_windows=self.legacy_windows, min_width=1, @@ -885,32 +953,54 @@ class Console: """ if self._width is not None and self._height is not None: - return ConsoleDimensions(self._width, self._height) + return ConsoleDimensions(self._width - self.legacy_windows, self._height) if self.is_dumb_terminal: return ConsoleDimensions(80, 25) width: Optional[int] = None height: Optional[int] = None + if WINDOWS: # pragma: no cover - width, height = shutil.get_terminal_size() + try: + width, height = os.get_terminal_size() + except OSError: # Probably not a terminal + pass else: try: - width, height = os.get_terminal_size(sys.stdin.fileno()) + width, height = os.get_terminal_size(sys.__stdin__.fileno()) except (AttributeError, ValueError, OSError): try: - width, height = os.get_terminal_size(sys.stdout.fileno()) + width, height = os.get_terminal_size(sys.__stdout__.fileno()) except (AttributeError, ValueError, OSError): pass + columns = self._environ.get("COLUMNS") + if columns is not None and columns.isdigit(): + width = int(columns) + lines = self._environ.get("LINES") + if lines is not None and lines.isdigit(): + height = int(lines) + # get_terminal_size can report 0, 0 if run from pseudo-terminal width = width or 80 height = height or 25 return ConsoleDimensions( - (width - self.legacy_windows) if self._width is None else self._width, + width - self.legacy_windows if self._width is None else self._width, height if self._height is None else self._height, ) + @size.setter + def size(self, new_size: Tuple[int, int]) -> None: + """Set a new size for the terminal. + + Args: + new_size (Tuple[int, int]): New width and height. + """ + width, height = new_size + self._width = width + self._height = height + @property def width(self) -> int: """Get the width of the console. @@ -918,8 +1008,16 @@ class Console: Returns: int: The width (in characters) of the console. """ - width, _ = self.size - return width + return self.size.width + + @width.setter + def width(self, width: int) -> None: + """Set width. + + Args: + width (int): New width. + """ + self._width = width @property def height(self) -> int: @@ -928,8 +1026,16 @@ class Console: Returns: int: The height (in lines) of the console. """ - _, height = self.size - return height + return self.size.height + + @height.setter + def height(self, height: int) -> None: + """Set height. + + Args: + height (int): new height. + """ + self._height = height def bell(self) -> None: """Play a 'bell' sound (if supported by the terminal).""" @@ -953,13 +1059,13 @@ class Console: return capture def pager( - self, pager: Pager = None, styles: bool = False, links: bool = False + self, pager: Optional[Pager] = None, styles: bool = False, links: bool = False ) -> PagerContext: """A context manager to display anything printed within a "pager". The pager application is defined by the system and will typically support at least pressing a key to scroll. Args: - pager (Pager, optional): A pager object, or None to use :class:~rich.pager.SystemPager`. Defaults to None. + pager (Pager, optional): A pager object, or None to use :class:`~rich.pager.SystemPager`. Defaults to None. styles (bool, optional): Show styles in pager. Defaults to False. links (bool, optional): Show links in pager. Defaults to False. @@ -1009,7 +1115,6 @@ class Console: Args: status (RenderableType): A status renderable (str or Text typically). - console (Console, optional): Console instance to use, or None for global console. Defaults to None. spinner (str, optional): Name of spinner animation (see python -m rich.spinner). Defaults to "dots". spinner_style (StyleType, optional): Style of spinner. Defaults to "status.spinner". speed (float, optional): Speed factor for spinner animation. Defaults to 1.0. @@ -1072,7 +1177,7 @@ class Console: return self._is_alt_screen def screen( - self, hide_cursor: bool = True, style: StyleType = None + self, hide_cursor: bool = True, style: Optional[StyleType] = None ) -> "ScreenContext": """Context manager to enable and disable 'alternative screen' mode. @@ -1085,8 +1190,25 @@ class Console: """ return ScreenContext(self, hide_cursor=hide_cursor, style=style or "") + def measure( + self, renderable: RenderableType, *, options: Optional[ConsoleOptions] = None + ) -> Measurement: + """Measure a renderable. Returns a :class:`~rich.measure.Measurement` object which contains + information regarding the number of characters required to print the renderable. + + Args: + renderable (RenderableType): Any renderable or string. + options (Optional[ConsoleOptions], optional): Options to use when measuring, or None + to use default options. Defaults to None. + + Returns: + Measurement: A measurement of the renderable. + """ + measurement = Measurement.get(self, options or self.options, renderable) + return measurement + def render( - self, renderable: RenderableType, options: ConsoleOptions = None + self, renderable: RenderableType, options: Optional[ConsoleOptions] = None ) -> Iterable[Segment]: """Render an object in to an iterable of `Segment` instances. @@ -1107,15 +1229,15 @@ class Console: # No space to render anything. This prevents potential recursion errors. return render_iterable: RenderResult - if hasattr(renderable, "__rich__"): - renderable = renderable.__rich__() # type: ignore - if hasattr(renderable, "__rich_console__"): + + renderable = rich_cast(renderable) + 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( renderable, highlight=_options.highlight, markup=_options.markup ) - render_iterable = text_renderable.__rich_console__(self, _options) # type: ignore + render_iterable = text_renderable.__rich_console__(self, _options) else: raise errors.NotRenderableError( f"Unable to render {renderable!r}; " @@ -1193,12 +1315,12 @@ class Console: text: str, *, style: Union[str, Style] = "", - justify: JustifyMethod = None, - overflow: OverflowMethod = None, - emoji: bool = None, - markup: bool = None, - highlight: bool = None, - highlighter: HighlighterType = None, + justify: Optional[JustifyMethod] = None, + overflow: Optional[OverflowMethod] = None, + emoji: Optional[bool] = None, + markup: Optional[bool] = None, + highlight: Optional[bool] = None, + highlighter: Optional[HighlighterType] = None, ) -> "Text": """Convert a string to a Text instance. This is is called automatically if you print or log a string. @@ -1221,12 +1343,19 @@ class Console: highlight_enabled = highlight or (highlight is None and self._highlight) if markup_enabled: - rich_text = render_markup(text, style=style, emoji=emoji_enabled) + rich_text = render_markup( + text, + style=style, + emoji=emoji_enabled, + emoji_variant=self._emoji_variant, + ) rich_text.justify = justify rich_text.overflow = overflow else: rich_text = Text( - _emoji_replace(text) if emoji_enabled else text, + _emoji_replace(text, default_variant=self._emoji_variant) + if emoji_enabled + else text, justify=justify, overflow=overflow, style=style, @@ -1241,7 +1370,7 @@ class Console: return rich_text def get_style( - self, name: Union[str, Style], *, default: Union[Style, str] = None + self, name: Union[str, Style], *, default: Optional[Union[Style, str]] = None ) -> Style: """Get a Style instance by it's theme name or parse a definition. @@ -1276,10 +1405,10 @@ class Console: sep: str, end: str, *, - justify: JustifyMethod = None, - emoji: bool = None, - markup: bool = None, - highlight: bool = None, + justify: Optional[JustifyMethod] = None, + emoji: Optional[bool] = None, + markup: Optional[bool] = None, + highlight: Optional[bool] = None, ) -> List[ConsoleRenderable]: """Combine a number of renderables and text into one renderable. @@ -1319,21 +1448,15 @@ class Console: del text[:] for renderable in objects: - # I promise this is sane - # This detects an object which claims to have all attributes, such as MagicMock.mock_calls - if hasattr( - renderable, "jwevpw_eors4dfo6mwo345ermk7kdnfnwerwer" - ): # pragma: no cover - renderable = repr(renderable) - rich_cast = getattr(renderable, "__rich__", None) - if rich_cast: - renderable = rich_cast() + renderable = rich_cast(renderable) if isinstance(renderable, str): append_text( self.render_str( renderable, emoji=emoji, markup=markup, highlighter=_highlighter ) ) + elif isinstance(renderable, Text): + append_text(renderable) elif isinstance(renderable, ConsoleRenderable): check_text() append(renderable) @@ -1379,17 +1502,16 @@ class Console: control_codes (str): Control codes, such as those that may move the cursor. """ if not self.is_dumb_terminal: - for _control in control: - self._buffer.append(_control.segment) - self._check_buffer() + with self: + self._buffer.extend(_control.segment for _control in control) def out( self, *objects: Any, - sep=" ", - end="\n", - style: Union[str, Style] = None, - highlight: bool = None, + sep: str = " ", + end: str = "\n", + style: Optional[Union[str, Style]] = None, + highlight: Optional[bool] = None, ) -> None: """Output to the terminal. This is a low-level way of writing to the terminal which unlike :meth:`~rich.console.Console.print` won't pretty print, wrap text, or apply markup, but will @@ -1418,19 +1540,20 @@ class Console: def print( self, *objects: Any, - sep=" ", - end="\n", - style: Union[str, Style] = None, - justify: JustifyMethod = None, - overflow: OverflowMethod = None, - no_wrap: bool = None, - emoji: bool = None, - markup: bool = None, - highlight: bool = None, - width: int = None, - height: int = None, + sep: str = " ", + end: str = "\n", + style: Optional[Union[str, Style]] = None, + justify: Optional[JustifyMethod] = None, + overflow: Optional[OverflowMethod] = None, + no_wrap: Optional[bool] = None, + emoji: Optional[bool] = None, + markup: Optional[bool] = None, + highlight: Optional[bool] = None, + width: Optional[int] = None, + height: Optional[int] = None, crop: bool = True, - soft_wrap: bool = None, + soft_wrap: Optional[bool] = None, + new_line_start: bool = False, ) -> None: """Print to the console. @@ -1447,8 +1570,9 @@ class Console: highlight (Optional[bool], optional): Enable automatic highlighting, or ``None`` to use console default. Defaults to ``None``. width (Optional[int], optional): Width of output, or ``None`` to auto-detect. Defaults to ``None``. crop (Optional[bool], optional): Crop output to width of terminal. Defaults to True. - soft_wrap (bool, optional): Enable soft wrap mode which disables word wrapping and cropping of text or None for + soft_wrap (bool, optional): Enable soft wrap mode which disables word wrapping and cropping of text or ``None`` for Console default. Defaults to ``None``. + new_line_start (bool, False): Insert a new line at the start if the output contains more than one line. Defaults to ``False``. """ if not objects: objects = (NewLine(),) @@ -1461,7 +1585,7 @@ class Console: if overflow is None: overflow = "ignore" crop = False - + render_hooks = self._render_hooks[:] with self: renderables = self._collect_renderables( objects, @@ -1472,7 +1596,7 @@ class Console: markup=markup, highlight=highlight, ) - for hook in self._render_hooks: + for hook in render_hooks: renderables = hook.process_renderables(renderables) render_options = self.options.update( justify=justify, @@ -1481,6 +1605,7 @@ class Console: height=height, no_wrap=no_wrap, markup=markup, + highlight=highlight, ) new_segments: List[Segment] = [] @@ -1496,6 +1621,12 @@ class Console: render(renderable, render_options), self.get_style(style) ) ) + if new_line_start: + if ( + len("".join(segment.text for segment in new_segments).splitlines()) + > 1 + ): + new_segments.insert(0, Segment.line()) if crop: buffer_extend = self._buffer.extend for line in Segment.split_and_crop_lines( @@ -1505,12 +1636,73 @@ class Console: else: self._buffer.extend(new_segments) + def print_json( + self, + json: Optional[str] = None, + *, + data: Any = None, + indent: Union[None, int, str] = 2, + highlight: bool = True, + skip_keys: bool = False, + ensure_ascii: bool = True, + check_circular: bool = True, + allow_nan: bool = True, + default: Optional[Callable[[Any], Any]] = None, + sort_keys: bool = False, + ) -> None: + """Pretty prints JSON. Output will be valid JSON. + + Args: + json (Optional[str]): A string containing JSON. + data (Any): If json is not supplied, then encode this data. + indent (Union[None, int, str], optional): Number of spaces to indent. Defaults to 2. + highlight (bool, optional): Enable highlighting of output: Defaults to True. + skip_keys (bool, optional): Skip keys not of a basic type. Defaults to False. + ensure_ascii (bool, optional): Escape all non-ascii characters. Defaults to False. + check_circular (bool, optional): Check for circular references. Defaults to True. + allow_nan (bool, optional): Allow NaN and Infinity values. Defaults to True. + default (Callable, optional): A callable that converts values that can not be encoded + in to something that can be JSON encoded. Defaults to None. + sort_keys (bool, optional): Sort dictionary keys. Defaults to False. + """ + from rich.json import JSON + + if json is None: + json_renderable = JSON.from_data( + data, + indent=indent, + highlight=highlight, + skip_keys=skip_keys, + ensure_ascii=ensure_ascii, + check_circular=check_circular, + allow_nan=allow_nan, + default=default, + sort_keys=sort_keys, + ) + else: + if not isinstance(json, str): + raise TypeError( + f"json must be str. Did you mean print_json(data={json!r}) ?" + ) + json_renderable = JSON( + json, + indent=indent, + highlight=highlight, + skip_keys=skip_keys, + ensure_ascii=ensure_ascii, + check_circular=check_circular, + allow_nan=allow_nan, + default=default, + sort_keys=sort_keys, + ) + self.print(json_renderable, soft_wrap=True) + def update_screen( self, renderable: RenderableType, *, - region: Region = None, - options: ConsoleOptions = None, + region: Optional[Region] = None, + options: Optional[ConsoleOptions] = None, ) -> None: """Update the screen at a given offset. @@ -1539,7 +1731,9 @@ class Console: lines = self.render_lines(renderable, options=render_options) self.update_screen_lines(lines, x, y) - def update_screen_lines(self, lines: List[List[Segment]], x: int = 0, y: int = 0): + def update_screen_lines( + self, lines: List[List[Segment]], x: int = 0, y: int = 0 + ) -> None: """Update lines of the screen at a given offset. Args: @@ -1565,6 +1759,8 @@ class Console: theme: Optional[str] = None, word_wrap: bool = False, show_locals: bool = False, + suppress: Iterable[Union[str, ModuleType]] = (), + max_frames: int = 100, ) -> None: """Prints a rich render of the last exception and traceback. @@ -1574,6 +1770,8 @@ class Console: theme (str, optional): Override pygments theme used in traceback word_wrap (bool, optional): Enable word wrapping of long lines. Defaults to False. show_locals (bool, optional): Enable display of local variables. Defaults to False. + suppress (Iterable[Union[str, ModuleType]]): Optional sequence of modules or paths to exclude from traceback. + max_frames (int): Maximum number of frames to show in a traceback, 0 for no maximum. Defaults to 100. """ from .traceback import Traceback @@ -1583,21 +1781,58 @@ class Console: theme=theme, word_wrap=word_wrap, show_locals=show_locals, + suppress=suppress, + max_frames=max_frames, ) self.print(traceback) + @staticmethod + def _caller_frame_info( + offset: int, + currentframe: Callable[[], Optional[FrameType]] = inspect.currentframe, + ) -> Tuple[str, int, Dict[str, Any]]: + """Get caller frame information. + + Args: + offset (int): the caller offset within the current frame stack. + currentframe (Callable[[], Optional[FrameType]], optional): the callable to use to + retrieve the current frame. Defaults to ``inspect.currentframe``. + + Returns: + Tuple[str, int, Dict[str, Any]]: A tuple containing the filename, the line number and + the dictionary of local variables associated with the caller frame. + + Raises: + RuntimeError: If the stack offset is invalid. + """ + # Ignore the frame of this local helper + offset += 1 + + frame = currentframe() + if frame is not None: + # Use the faster currentframe where implemented + while offset and frame: + frame = frame.f_back + offset -= 1 + assert frame is not None + return frame.f_code.co_filename, frame.f_lineno, frame.f_locals + else: + # Fallback to the slower stack + frame_info = inspect.stack()[offset] + return frame_info.filename, frame_info.lineno, frame_info.frame.f_locals + def log( self, *objects: Any, - sep=" ", - end="\n", - style: Union[str, Style] = None, - justify: JustifyMethod = None, - emoji: bool = None, - markup: bool = None, - highlight: bool = None, + sep: str = " ", + end: str = "\n", + style: Optional[Union[str, Style]] = None, + justify: Optional[JustifyMethod] = None, + emoji: Optional[bool] = None, + markup: Optional[bool] = None, + highlight: Optional[bool] = None, log_locals: bool = False, - _stack_offset=1, + _stack_offset: int = 1, ) -> None: """Log rich content to the terminal. @@ -1618,6 +1853,8 @@ class Console: if not objects: objects = (NewLine(),) + render_hooks = self._render_hooks[:] + with self: renderables = self._collect_renderables( objects, @@ -1631,18 +1868,13 @@ class Console: if style is not None: renderables = [Styled(renderable, style) for renderable in renderables] - caller = inspect.stack()[_stack_offset] - link_path = ( - None - if caller.filename.startswith("<") - else os.path.abspath(caller.filename) - ) - path = caller.filename.rpartition(os.sep)[-1] - line_no = caller.lineno + filename, line_no, locals = self._caller_frame_info(_stack_offset) + link_path = None if filename.startswith("<") else os.path.abspath(filename) + path = filename.rpartition(os.sep)[-1] if log_locals: locals_map = { key: value - for key, value in caller.frame.f_locals.items() + for key, value in locals.items() if not key.startswith("__") } renderables.append(render_scope(locals_map, title="[i]locals")) @@ -1657,7 +1889,7 @@ class Console: link_path=link_path, ) ] - for hook in self._render_hooks: + for hook in render_hooks: renderables = hook.process_renderables(renderables) new_segments: List[Segment] = [] extend = new_segments.extend @@ -1734,10 +1966,12 @@ class Console: markup: bool = True, emoji: bool = True, password: bool = False, - stream: TextIO = None, + stream: Optional[TextIO] = None, ) -> str: """Displays a prompt and waits for input from the user. The prompt may contain color / style. + It works in the same way as Python's builtin :func:`input` function and provides elaborate line editing and history features if Python's builtin :mod:`readline` module is previously loaded. + Args: prompt (Union[str, Text]): Text to render in the prompt. markup (bool, optional): Enable console markup (requires a str prompt). Defaults to True. @@ -1816,9 +2050,9 @@ class Console: def export_html( self, *, - theme: TerminalTheme = None, + theme: Optional[TerminalTheme] = None, clear: bool = True, - code_format: str = None, + code_format: Optional[str] = None, inline_styles: bool = False, ) -> str: """Generate HTML from console contents (requires record=True argument in constructor). @@ -1843,10 +2077,6 @@ class Console: _theme = theme or DEFAULT_TERMINAL_THEME stylesheet = "" - def escape(text: str) -> str: - """Escape html.""" - return text.replace("&", "&").replace("<", "<").replace(">", ">") - render_code_format = CONSOLE_HTML_FORMAT if code_format is None else code_format with self._record_buffer_lock: @@ -1896,9 +2126,9 @@ class Console: self, path: str, *, - theme: TerminalTheme = None, + theme: Optional[TerminalTheme] = None, clear: bool = True, - code_format=CONSOLE_HTML_FORMAT, + code_format: str = CONSOLE_HTML_FORMAT, inline_styles: bool = False, ) -> None: """Generate HTML from console contents and write to a file (requires record=True argument in constructor). diff --git a/libs/rich/containers.py b/libs/rich/containers.py index 0f13fb810..e29cf3689 100644 --- a/libs/rich/containers.py +++ b/libs/rich/containers.py @@ -3,6 +3,8 @@ from typing import ( Iterator, Iterable, List, + Optional, + Union, overload, TypeVar, TYPE_CHECKING, @@ -28,7 +30,9 @@ T = TypeVar("T") class Renderables: """A list subclass which renders its contents to the console.""" - def __init__(self, renderables: Iterable["RenderableType"] = None) -> None: + def __init__( + self, renderables: Optional[Iterable["RenderableType"]] = None + ) -> None: self._renderables: List["RenderableType"] = ( list(renderables) if renderables is not None else [] ) @@ -76,10 +80,10 @@ class Lines: ... @overload - def __getitem__(self, index: slice) -> "Lines": + def __getitem__(self, index: slice) -> List["Text"]: ... - def __getitem__(self, index): + def __getitem__(self, index: Union[slice, int]) -> Union["Text", List["Text"]]: return self._lines[index] def __setitem__(self, index: int, value: "Text") -> "Lines": @@ -101,7 +105,7 @@ class Lines: def extend(self, lines: Iterable["Text"]) -> None: self._lines.extend(lines) - def pop(self, index=-1) -> "Text": + def pop(self, index: int = -1) -> "Text": return self._lines.pop(index) def justify( diff --git a/libs/rich/control.py b/libs/rich/control.py index 8a6236437..c98d0d7d9 100644 --- a/libs/rich/control.py +++ b/libs/rich/control.py @@ -1,4 +1,4 @@ -from typing import Callable, Dict, Iterable, List, TYPE_CHECKING, Union +from typing import Any, Callable, Dict, Iterable, List, TYPE_CHECKING, Union from .segment import ControlCode, ControlType, Segment @@ -14,7 +14,7 @@ STRIP_CONTROL_CODES = [ _CONTROL_TRANSLATE = {_codepoint: None for _codepoint in STRIP_CONTROL_CODES} -CONTROL_CODES_FORMAT: Dict[int, Callable] = { +CONTROL_CODES_FORMAT: Dict[int, Callable[..., str]] = { ControlType.BELL: lambda: "\x07", ControlType.CARRIAGE_RETURN: lambda: "\r", ControlType.HOME: lambda: "\x1b[H", @@ -27,7 +27,7 @@ CONTROL_CODES_FORMAT: Dict[int, Callable] = { 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", } @@ -93,8 +93,8 @@ class Control: 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) @@ -106,14 +106,14 @@ class 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 @@ -157,7 +157,9 @@ class Control: yield self.segment -def strip_control_codes(text: str, _translate_table=_CONTROL_TRANSLATE) -> str: +def strip_control_codes( + text: str, _translate_table: Dict[int, None] = _CONTROL_TRANSLATE +) -> str: """Remove control codes from text. Args: diff --git a/libs/rich/default_styles.py b/libs/rich/default_styles.py index 1e87d6aea..63290bcab 100644 --- a/libs/rich/default_styles.py +++ b/libs/rich/default_styles.py @@ -2,6 +2,7 @@ from typing import Dict from .style import Style + DEFAULT_STYLES: Dict[str, Style] = { "none": Style.null(), "reset": Style( @@ -82,8 +83,18 @@ DEFAULT_STYLES: Dict[str, Style] = { "repr.none": Style(color="magenta", italic=True), "repr.url": Style(underline=True, color="bright_blue", italic=False, bold=False), "repr.uuid": Style(color="bright_yellow", bold=False), + "repr.call": Style(color="magenta", bold=True), + "repr.path": Style(color="magenta"), + "repr.filename": Style(color="bright_magenta"), "rule.line": Style(color="bright_green"), "rule.text": Style.null(), + "json.brace": Style(bold=True), + "json.bool_true": Style(color="bright_green", italic=True), + "json.bool_false": Style(color="bright_red", italic=True), + "json.null": Style(color="magenta", italic=True), + "json.number": Style(color="cyan", bold=True, italic=False), + "json.str": Style(color="green", italic=False, bold=False), + "json.key": Style(color="blue", bold=True), "prompt": Style.null(), "prompt.choices": Style(color="magenta", bold=True), "prompt.default": Style(color="cyan", bold=True), @@ -94,8 +105,6 @@ DEFAULT_STYLES: Dict[str, Style] = { "scope.key": Style(color="yellow", italic=True), "scope.key.special": Style(color="yellow", italic=True, dim=True), "scope.equals": Style(color="red"), - "repr.path": Style(color="magenta"), - "repr.filename": Style(color="bright_magenta"), "table.header": Style(bold=True), "table.footer": Style(bold=True), "table.cell": Style.null(), @@ -125,9 +134,6 @@ DEFAULT_STYLES: Dict[str, Style] = { "status.spinner": Style(color="green"), "tree": Style(), "tree.line": Style(), -} - -MARKDOWN_STYLES = { "markdown.paragraph": Style(), "markdown.text": Style(), "markdown.emph": Style(italic=True), @@ -153,4 +159,25 @@ MARKDOWN_STYLES = { } -DEFAULT_STYLES.update(MARKDOWN_STYLES) +if __name__ == "__main__": # pragma: no cover + import argparse + import io + + from rich.console import Console + from rich.table import Table + from rich.text import Text + + parser = argparse.ArgumentParser() + parser.add_argument("--html", action="store_true", help="Export as HTML table") + args = parser.parse_args() + html: bool = args.html + console = Console(record=True, width=70, file=io.StringIO()) if html else Console() + + table = Table("Name", "Styling") + + for style_name, style in DEFAULT_STYLES.items(): + table.add_row(Text(style_name, style=style), str(style)) + + console.print(table) + if html: + print(console.export_html(inline_styles=True)) diff --git a/libs/rich/emoji.py b/libs/rich/emoji.py index 4c1e200c4..d5a1062a9 100644 --- a/libs/rich/emoji.py +++ b/libs/rich/emoji.py @@ -1,21 +1,40 @@ -from typing import Union +import sys +from typing import TYPE_CHECKING, Optional, Union -from .console import Console, ConsoleOptions, RenderResult from .jupyter import JupyterMixin from .segment import Segment from .style import Style from ._emoji_codes import EMOJI from ._emoji_replace import _emoji_replace +if sys.version_info >= (3, 8): + from typing import Literal +else: + from typing_extensions import Literal # pragma: no cover + + +if TYPE_CHECKING: + from .console import Console, ConsoleOptions, RenderResult + + +EmojiVariant = Literal["emoji", "text"] + class NoEmoji(Exception): """No emoji by that name.""" class Emoji(JupyterMixin): - __slots__ = ["name", "style", "_char"] + __slots__ = ["name", "style", "_char", "variant"] + + VARIANTS = {"text": "\uFE0E", "emoji": "\uFE0F"} - def __init__(self, name: str, style: Union[str, Style] = "none") -> None: + def __init__( + self, + name: str, + style: Union[str, Style] = "none", + variant: Optional[EmojiVariant] = None, + ) -> None: """A single emoji character. Args: @@ -27,10 +46,13 @@ class Emoji(JupyterMixin): """ self.name = name self.style = style + self.variant = variant try: self._char = EMOJI[name] except KeyError: raise NoEmoji(f"No emoji called {name!r}") + if variant is not None: + self._char += self.VARIANTS.get(variant, "") @classmethod def replace(cls, text: str) -> str: @@ -51,8 +73,8 @@ class Emoji(JupyterMixin): return self._char def __rich_console__( - self, console: Console, options: ConsoleOptions - ) -> RenderResult: + self, console: "Console", options: "ConsoleOptions" + ) -> "RenderResult": yield Segment(self._char, console.get_style(self.style)) diff --git a/libs/rich/file_proxy.py b/libs/rich/file_proxy.py index 99a6922cb..3ec593a5a 100644 --- a/libs/rich/file_proxy.py +++ b/libs/rich/file_proxy.py @@ -44,7 +44,7 @@ class FileProxy(io.TextIOBase): output = Text("\n").join( self.__ansi_decoder.decode_line(line) for line in lines ) - console.print(output, markup=False, emoji=False, highlight=False) + console.print(output) return len(text) def flush(self) -> None: diff --git a/libs/rich/filesize.py b/libs/rich/filesize.py index 487682330..b3a0996b0 100644 --- a/libs/rich/filesize.py +++ b/libs/rich/filesize.py @@ -13,10 +13,17 @@ See Also: __all__ = ["decimal"] -from typing import Iterable, List, Tuple +from typing import Iterable, List, Tuple, Optional -def _to_str(size: int, suffixes: Iterable[str], base: int) -> str: +def _to_str( + size: int, + suffixes: Iterable[str], + base: int, + *, + precision: Optional[int] = 1, + separator: Optional[str] = " ", +) -> str: if size == 1: return "1 byte" elif size < base: @@ -26,7 +33,12 @@ def _to_str(size: int, suffixes: Iterable[str], base: int) -> str: unit = base ** i if size < unit: break - return "{:,.1f} {}".format((base * size / unit), suffix) + return "{:,.{precision}f}{separator}{}".format( + (base * size / unit), + suffix, + precision=precision, + separator=separator, + ) def pick_unit_and_suffix(size: int, suffixes: List[str], base: int) -> Tuple[int, str]: @@ -38,7 +50,12 @@ def pick_unit_and_suffix(size: int, suffixes: List[str], base: int) -> Tuple[int return unit, suffix -def decimal(size: int) -> str: +def decimal( + size: int, + *, + precision: Optional[int] = 1, + separator: Optional[str] = " ", +) -> str: """Convert a filesize in to a string (powers of 1000, SI prefixes). In this convention, ``1000 B = 1 kB``. @@ -50,6 +67,8 @@ def decimal(size: int) -> str: Arguments: int (size): A file size. + int (precision): The number of decimal places to include (default = 1). + str (separator): The string to separate the value from the units (default = " "). Returns: `str`: A string containing a abbreviated file size and units. @@ -57,6 +76,14 @@ def decimal(size: int) -> str: Example: >>> filesize.decimal(30000) '30.0 kB' + >>> filesize.decimal(30000, precision=2, separator="") + '30.00kB' """ - return _to_str(size, ("kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"), 1000) + return _to_str( + size, + ("kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"), + 1000, + precision=precision, + separator=separator, + ) diff --git a/libs/rich/highlighter.py b/libs/rich/highlighter.py index c2ee7cc76..8afdd017b 100644 --- a/libs/rich/highlighter.py +++ b/libs/rich/highlighter.py @@ -81,22 +81,38 @@ class ReprHighlighter(RegexHighlighter): base_style = "repr." highlights = [ - r"(?P<tag_start>\<)(?P<tag_name>[\w\-\.\:]*)(?P<tag_contents>.*?)(?P<tag_end>\>)", + r"(?P<tag_start>\<)(?P<tag_name>[\w\-\.\:]*)(?P<tag_contents>[\w\W]*?)(?P<tag_end>\>)", r"(?P<attrib_name>[\w_]{1,50})=(?P<attrib_value>\"?[\w_]+\"?)?", + r"(?P<brace>[\{\[\(\)\]\}])", _combine_regex( r"(?P<ipv4>[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})", r"(?P<ipv6>([A-Fa-f0-9]{1,4}::?){1,7}[A-Fa-f0-9]{1,4})", r"(?P<eui64>(?:[0-9A-Fa-f]{1,2}-){7}[0-9A-Fa-f]{1,2}|(?:[0-9A-Fa-f]{1,2}:){7}[0-9A-Fa-f]{1,2}|(?:[0-9A-Fa-f]{4}\.){3}[0-9A-Fa-f]{4})", r"(?P<eui48>(?:[0-9A-Fa-f]{1,2}-){5}[0-9A-Fa-f]{1,2}|(?:[0-9A-Fa-f]{1,2}:){5}[0-9A-Fa-f]{1,2}|(?:[0-9A-Fa-f]{4}\.){2}[0-9A-Fa-f]{4})", - r"(?P<brace>[\{\[\(\)\]\}])", - r"(?P<bool_true>True)|(?P<bool_false>False)|(?P<none>None)", + r"(?P<call>[\w\.]*?)\(", + r"\b(?P<bool_true>True)\b|\b(?P<bool_false>False)\b|\b(?P<none>None)\b", r"(?P<ellipsis>\.\.\.)", r"(?P<number>(?<!\w)\-?[0-9]+\.?[0-9]*(e[\-\+]?\d+?)?\b|0x[0-9a-fA-F]*)", r"(?P<path>\B(\/[\w\.\-\_\+]+)*\/)(?P<filename>[\w\.\-\_\+]*)?", r"(?<![\\\w])(?P<str>b?\'\'\'.*?(?<!\\)\'\'\'|b?\'.*?(?<!\\)\'|b?\"\"\".*?(?<!\\)\"\"\"|b?\".*?(?<!\\)\")", r"(?P<uuid>[a-fA-F0-9]{8}\-[a-fA-F0-9]{4}\-[a-fA-F0-9]{4}\-[a-fA-F0-9]{4}\-[a-fA-F0-9]{12})", - r"(?P<url>(https|http|ws|wss):\/\/[0-9a-zA-Z\$\-\_\+\!`\(\)\,\.\?\/\;\:\&\=\%\#]*)", + r"(?P<url>(file|https|http|ws|wss):\/\/[0-9a-zA-Z\$\-\_\+\!`\(\)\,\.\?\/\;\:\&\=\%\#]*)", + ), + ] + + +class JSONHighlighter(RegexHighlighter): + """Highlights JSON""" + + base_style = "json." + highlights = [ + _combine_regex( + r"(?P<brace>[\{\[\(\)\]\}])", + r"\b(?P<bool_true>true)\b|\b(?P<bool_false>false)\b|\b(?P<null>null)\b", + r"(?P<number>(?<!\w)\-?[0-9]+\.?[0-9]*(e[\-\+]?\d+?)?\b|0x[0-9a-fA-F]*)", + r"(?<![\\\w])(?P<str>b?\".*?(?<!\\)\")", ), + r"(?<![\\\w])(?P<key>b?\".*?(?<!\\)\")\:", ] diff --git a/libs/rich/json.py b/libs/rich/json.py new file mode 100644 index 000000000..1fc115a95 --- /dev/null +++ b/libs/rich/json.py @@ -0,0 +1,140 @@ +from json import loads, dumps +from typing import Any, Callable, Optional, Union + +from .text import Text +from .highlighter import JSONHighlighter, NullHighlighter + + +class JSON: + """A renderable which pretty prints JSON. + + Args: + json (str): JSON encoded data. + indent (Union[None, int, str], optional): Number of characters to indent by. Defaults to 2. + highlight (bool, optional): Enable highlighting. Defaults to True. + skip_keys (bool, optional): Skip keys not of a basic type. Defaults to False. + ensure_ascii (bool, optional): Escape all non-ascii characters. Defaults to False. + check_circular (bool, optional): Check for circular references. Defaults to True. + allow_nan (bool, optional): Allow NaN and Infinity values. Defaults to True. + default (Callable, optional): A callable that converts values that can not be encoded + in to something that can be JSON encoded. Defaults to None. + sort_keys (bool, optional): Sort dictionary keys. Defaults to False. + """ + + def __init__( + self, + json: str, + indent: Union[None, int, str] = 2, + highlight: bool = True, + skip_keys: bool = False, + ensure_ascii: bool = True, + check_circular: bool = True, + allow_nan: bool = True, + default: Optional[Callable[[Any], Any]] = None, + sort_keys: bool = False, + ) -> None: + data = loads(json) + json = dumps( + data, + indent=indent, + skipkeys=skip_keys, + ensure_ascii=ensure_ascii, + check_circular=check_circular, + allow_nan=allow_nan, + default=default, + sort_keys=sort_keys, + ) + highlighter = JSONHighlighter() if highlight else NullHighlighter() + self.text = highlighter(json) + self.text.no_wrap = True + self.text.overflow = None + + @classmethod + def from_data( + cls, + data: Any, + indent: Union[None, int, str] = 2, + highlight: bool = True, + skip_keys: bool = False, + ensure_ascii: bool = True, + check_circular: bool = True, + allow_nan: bool = True, + default: Optional[Callable[[Any], Any]] = None, + sort_keys: bool = False, + ) -> "JSON": + """Encodes a JSON object from arbitrary data. + + Args: + data (Any): An object that may be encoded in to JSON + indent (Union[None, int, str], optional): Number of characters to indent by. Defaults to 2. + highlight (bool, optional): Enable highlighting. Defaults to True. + default (Callable, optional): Optional callable which will be called for objects that cannot be serialized. Defaults to None. + skip_keys (bool, optional): Skip keys not of a basic type. Defaults to False. + ensure_ascii (bool, optional): Escape all non-ascii characters. Defaults to False. + check_circular (bool, optional): Check for circular references. Defaults to True. + allow_nan (bool, optional): Allow NaN and Infinity values. Defaults to True. + default (Callable, optional): A callable that converts values that can not be encoded + in to something that can be JSON encoded. Defaults to None. + sort_keys (bool, optional): Sort dictionary keys. Defaults to False. + + Returns: + JSON: New JSON object from the given data. + """ + json_instance: "JSON" = cls.__new__(cls) + json = dumps( + data, + indent=indent, + skipkeys=skip_keys, + ensure_ascii=ensure_ascii, + check_circular=check_circular, + allow_nan=allow_nan, + default=default, + sort_keys=sort_keys, + ) + highlighter = JSONHighlighter() if highlight else NullHighlighter() + json_instance.text = highlighter(json) + json_instance.text.no_wrap = True + json_instance.text.overflow = None + return json_instance + + def __rich__(self) -> Text: + return self.text + + +if __name__ == "__main__": + + import argparse + import sys + + parser = argparse.ArgumentParser(description="Pretty print json") + parser.add_argument( + "path", + metavar="PATH", + help="path to file, or - for stdin", + ) + parser.add_argument( + "-i", + "--indent", + metavar="SPACES", + type=int, + help="Number of spaces in an indent", + default=2, + ) + args = parser.parse_args() + + from rich.console import Console + + console = Console() + error_console = Console(stderr=True) + + try: + if args.path == "-": + json_data = sys.stdin.read() + else: + with open(args.path, "rt") as json_file: + json_data = json_file.read() + except Exception as error: + error_console.print(f"Unable to read {args.path!r}; {error}") + sys.exit(-1) + + console.print(JSON(json_data, indent=args.indent), soft_wrap=True) diff --git a/libs/rich/jupyter.py b/libs/rich/jupyter.py index c4d665690..bedf5cb19 100644 --- a/libs/rich/jupyter.py +++ b/libs/rich/jupyter.py @@ -1,10 +1,9 @@ -from typing import Iterable, List +from typing import Any, Dict, Iterable, List from . import get_console from .segment import Segment from .terminal_theme import DEFAULT_TERMINAL_THEME - JUPYTER_HTML_FORMAT = """\ <pre style="white-space:pre;overflow-x:auto;line-height:normal;font-family:Menlo,'DejaVu Sans Mono',consolas,'Courier New',monospace">{code}</pre> """ @@ -17,7 +16,9 @@ class JupyterRenderable: self.html = html self.text = text - def _repr_mimebundle_(self, include, exclude, **kwargs): + def _repr_mimebundle_( + self, include: Iterable[str], exclude: Iterable[str], **kwargs: Any + ) -> Dict[str, str]: data = {"text/plain": self.text, "text/html": self.html} if include: data = {k: v for (k, v) in data.items() if k in include} @@ -29,7 +30,11 @@ class JupyterRenderable: class JupyterMixin: """Add to an Rich renderable to make it render in Jupyter notebook.""" - def _repr_mimebundle_(self, include, exclude, **kwargs): + __slots__ = () + + def _repr_mimebundle_( + self, include: Iterable[str], exclude: Iterable[str], **kwargs: Any + ) -> Dict[str, str]: console = get_console() segments = list(console.render(self, console.options)) # type: ignore html = _render_segments(segments) @@ -69,14 +74,19 @@ def _render_segments(segments: Iterable[Segment]) -> str: def display(segments: Iterable[Segment], text: str) -> None: """Render segments to Jupyter.""" - from IPython.display import display as ipython_display - html = _render_segments(segments) jupyter_renderable = JupyterRenderable(html, text) - ipython_display(jupyter_renderable) + try: + from IPython.display import display as ipython_display + + ipython_display(jupyter_renderable) + except ModuleNotFoundError: + # Handle the case where the Console has force_jupyter=True, + # but IPython is not installed. + pass -def print(*args, **kwargs) -> None: +def print(*args: Any, **kwargs: Any) -> None: """Proxy for Console print.""" console = get_console() return console.print(*args, **kwargs) diff --git a/libs/rich/layout.py b/libs/rich/layout.py index 21057828e..e25ced675 100644 --- a/libs/rich/layout.py +++ b/libs/rich/layout.py @@ -20,7 +20,7 @@ from .console import Console, ConsoleOptions, RenderableType, RenderResult from .highlighter import ReprHighlighter from .panel import Panel from .pretty import Pretty -from .repr import rich_repr, RichReprResult +from .repr import rich_repr, Result from .region import Region from .segment import Segment from .style import StyleType @@ -154,14 +154,14 @@ class Layout: def __init__( self, - renderable: RenderableType = None, + renderable: Optional[RenderableType] = None, *, - name: str = None, - size: int = None, + name: Optional[str] = None, + size: Optional[int] = None, minimum_size: int = 1, ratio: int = 1, visible: bool = True, - height: int = None, + height: Optional[int] = None, ) -> None: self._renderable = renderable or _Placeholder(self) self.size = size @@ -175,7 +175,7 @@ class Layout: self._render_map: RenderMap = {} self._lock = RLock() - def __rich_repr__(self) -> RichReprResult: + def __rich_repr__(self) -> Result: yield "name", self.name, None yield "size", self.size, None yield "minimum_size", self.minimum_size, 1 @@ -191,6 +191,11 @@ class Layout: """Gets (visible) layout children.""" return [child for child in self._children if child.visible] + @property + def map(self) -> RenderMap: + """Get a map of the last render.""" + return self._render_map + def get(self, name: str) -> Optional["Layout"]: """Get a named layout, or None if it doesn't exist. @@ -222,7 +227,7 @@ class Layout: from rich.table import Table from rich.tree import Tree - def summary(layout) -> Table: + def summary(layout: "Layout") -> Table: icon = layout.splitter.get_tree_icon() @@ -412,9 +417,8 @@ class Layout: yield new_line -if __name__ == "__main__": # type: ignore +if __name__ == "__main__": from rich.console import Console - from rich.panel import Panel console = Console() layout = Layout() diff --git a/libs/rich/live.py b/libs/rich/live.py index 428228f64..6db5b605f 100644 --- a/libs/rich/live.py +++ b/libs/rich/live.py @@ -1,6 +1,7 @@ import sys from threading import Event, RLock, Thread -from typing import IO, Any, Callable, List, Optional +from types import TracebackType +from typing import IO, Any, Callable, List, Optional, TextIO, Type, cast from . import get_console from .console import Console, ConsoleRenderable, RenderableType, RenderHook @@ -49,9 +50,9 @@ class Live(JupyterMixin, RenderHook): def __init__( self, - renderable: RenderableType = None, + renderable: Optional[RenderableType] = None, *, - console: Console = None, + console: Optional[Console] = None, screen: bool = False, auto_refresh: bool = True, refresh_per_second: float = 4, @@ -59,7 +60,7 @@ class Live(JupyterMixin, RenderHook): redirect_stdout: bool = True, redirect_stderr: bool = True, vertical_overflow: VerticalOverflowMethod = "ellipsis", - get_renderable: Callable[[], RenderableType] = None, + get_renderable: Optional[Callable[[], RenderableType]] = None, ) -> None: assert refresh_per_second > 0, "refresh_per_second must be > 0" self._renderable = renderable @@ -100,7 +101,7 @@ class Live(JupyterMixin, RenderHook): ) return renderable or "" - def start(self, refresh=False) -> None: + def start(self, refresh: bool = False) -> None: """Start live rendering display. Args: @@ -129,60 +130,59 @@ class Live(JupyterMixin, RenderHook): return self.console.clear_live() self._started = False - try: - if self.auto_refresh and self._refresh_thread is not None: - self._refresh_thread.stop() - # allow it to fully render on the last even if overflow - self.vertical_overflow = "visible" - if not self._alt_screen and not self.console.is_jupyter: - self.refresh() - - finally: - self._disable_redirect_io() - self.console.pop_render_hook() - if not self._alt_screen and self.console.is_terminal: - self.console.line() - self.console.show_cursor(True) - if self._alt_screen: - self.console.set_alt_screen(False) - - if self._refresh_thread is not None: - self._refresh_thread.join() - self._refresh_thread = None - if self.transient and not self._alt_screen: - self.console.control(self._live_render.restore_cursor()) - if self.ipy_widget is not None: # pragma: no cover - if self.transient: - self.ipy_widget.close() - else: - # jupyter last refresh must occur after console pop render hook - # i am not sure why this is needed - self.refresh() + + if self.auto_refresh and self._refresh_thread is not None: + self._refresh_thread.stop() + self._refresh_thread = None + # allow it to fully render on the last even if overflow + self.vertical_overflow = "visible" + with self.console: + try: + if not self._alt_screen and not self.console.is_jupyter: + self.refresh() + finally: + self._disable_redirect_io() + self.console.pop_render_hook() + if not self._alt_screen and self.console.is_terminal: + self.console.line() + self.console.show_cursor(True) + if self._alt_screen: + self.console.set_alt_screen(False) + + if self.transient and not self._alt_screen: + self.console.control(self._live_render.restore_cursor()) + if self.ipy_widget is not None and self.transient: + self.ipy_widget.close() # pragma: no cover def __enter__(self) -> "Live": self.start(refresh=self._renderable is not None) return self - def __exit__(self, exc_type, exc_val, exc_tb) -> None: + def __exit__( + self, + exc_type: Optional[Type[BaseException]], + exc_val: Optional[BaseException], + exc_tb: Optional[TracebackType], + ) -> None: self.stop() - def _enable_redirect_io(self): + def _enable_redirect_io(self) -> None: """Enable redirecting of stdout / stderr.""" - if self.console.is_terminal: - if self._redirect_stdout and not isinstance(sys.stdout, FileProxy): # type: ignore + if self.console.is_terminal or self.console.is_jupyter: + if self._redirect_stdout and not isinstance(sys.stdout, FileProxy): self._restore_stdout = sys.stdout - sys.stdout = FileProxy(self.console, sys.stdout) - if self._redirect_stderr and not isinstance(sys.stderr, FileProxy): # type: ignore + sys.stdout = cast("TextIO", FileProxy(self.console, sys.stdout)) + if self._redirect_stderr and not isinstance(sys.stderr, FileProxy): self._restore_stderr = sys.stderr - sys.stderr = FileProxy(self.console, sys.stderr) + sys.stderr = cast("TextIO", FileProxy(self.console, sys.stderr)) - def _disable_redirect_io(self): + def _disable_redirect_io(self) -> None: """Disable redirecting of stdout / stderr.""" if self._restore_stdout: - sys.stdout = self._restore_stdout + sys.stdout = cast("TextIO", self._restore_stdout) self._restore_stdout = None if self._restore_stderr: - sys.stderr = self._restore_stderr + sys.stderr = cast("TextIO", self._restore_stderr) self._restore_stderr = None @property @@ -249,11 +249,7 @@ class Live(JupyterMixin, RenderHook): if self._alt_screen else self._live_render.position_cursor() ) - renderables = [ - reset, - *renderables, - self._live_render, - ] + renderables = [reset, *renderables, self._live_render] elif ( not self._started and not self.transient ): # if it is finished render the final output for files or dumb_terminals @@ -270,7 +266,7 @@ if __name__ == "__main__": # pragma: no cover from .align import Align from .console import Console - from .live import Live + from .live import Live as Live from .panel import Panel from .rule import Rule from .syntax import Syntax diff --git a/libs/rich/live_render.py b/libs/rich/live_render.py index 4294b9923..f6fa7b2da 100644 --- a/libs/rich/live_render.py +++ b/libs/rich/live_render.py @@ -1,7 +1,11 @@ -from threading import RLock +import sys from typing import Optional, Tuple -from typing_extensions import Literal +if sys.version_info >= (3, 8): + from typing import Literal +else: + from typing_extensions import Literal # pragma: no cover + from ._loop import loop_last from .console import Console, ConsoleOptions, RenderableType, RenderResult @@ -80,16 +84,15 @@ class LiveRender: ) -> RenderResult: renderable = self.renderable - _Segment = Segment style = console.get_style(self.style) lines = console.render_lines(renderable, options, style=style, pad=False) - shape = _Segment.get_shape(lines) + shape = Segment.get_shape(lines) _, height = shape if height > options.size.height: if self.vertical_overflow == "crop": lines = lines[: options.size.height] - shape = _Segment.get_shape(lines) + shape = Segment.get_shape(lines) elif self.vertical_overflow == "ellipsis": lines = lines[: (options.size.height - 1)] overflow_text = Text( @@ -100,10 +103,11 @@ class LiveRender: style="live.ellipsis", ) lines.append(list(console.render(overflow_text))) - shape = _Segment.get_shape(lines) + shape = Segment.get_shape(lines) self._shape = shape + new_line = Segment.line() for last, line in loop_last(lines): yield from line if not last: - yield _Segment.line() + yield new_line diff --git a/libs/rich/logging.py b/libs/rich/logging.py index 04b466003..002f1f7bf 100644 --- a/libs/rich/logging.py +++ b/libs/rich/logging.py @@ -58,14 +58,14 @@ class RichHandler(Handler): def __init__( self, level: Union[int, str] = logging.NOTSET, - console: Console = None, + console: Optional[Console] = None, *, show_time: bool = True, omit_repeated_times: bool = True, show_level: bool = True, show_path: bool = True, enable_link_path: bool = True, - highlighter: Highlighter = None, + highlighter: Optional[Highlighter] = None, markup: bool = False, rich_tracebacks: bool = False, tracebacks_width: Optional[int] = None, @@ -142,7 +142,7 @@ class RichHandler(Handler): if self.formatter: record.message = record.getMessage() formatter = self.formatter - if hasattr(formatter, "usesTime") and formatter.usesTime(): # type: ignore + if hasattr(formatter, "usesTime") and formatter.usesTime(): record.asctime = formatter.formatTime(record, formatter.datefmt) message = formatter.formatMessage(record) @@ -150,23 +150,27 @@ class RichHandler(Handler): log_renderable = self.render( record=record, traceback=traceback, message_renderable=message_renderable ) - self.console.print(log_renderable) + try: + self.console.print(log_renderable) + except Exception: + self.handleError(record) def render_message(self, record: LogRecord, message: str) -> "ConsoleRenderable": """Render message text in to Text. record (LogRecord): logging Record. - message (str): String cotaining log message. + message (str): String containing log message. Returns: ConsoleRenderable: Renderable to display log message. """ - use_markup = ( - getattr(record, "markup") if hasattr(record, "markup") else self.markup - ) + use_markup = getattr(record, "markup", self.markup) message_text = Text.from_markup(message) if use_markup else Text(message) - if self.highlighter: - message_text = self.highlighter(message_text) + + highlighter = getattr(record, "highlighter", self.highlighter) + if highlighter: + message_text = highlighter(message_text) + if self.KEYWORDS: message_text.highlight_words(self.KEYWORDS, "logging.keyword") return message_text @@ -210,7 +214,7 @@ if __name__ == "__main__": # pragma: no cover from time import sleep FORMAT = "%(message)s" - # FORMAT = "%(asctime)-15s - %(level) - %(message)s" + # FORMAT = "%(asctime)-15s - %(levelname)s - %(message)s" logging.basicConfig( level="NOTSET", format=FORMAT, @@ -247,7 +251,7 @@ if __name__ == "__main__": # pragma: no cover log.info("POST /admin/ 401 42234") log.warning("password was rejected for admin site.") - def divide(): + def divide() -> None: number = 1 divisor = 0 foos = ["foo"] * 100 diff --git a/libs/rich/markdown.py b/libs/rich/markdown.py index 23c6124d6..92d0d3c01 100644 --- a/libs/rich/markdown.py +++ b/libs/rich/markdown.py @@ -5,11 +5,12 @@ from commonmark.blocks import Parser from . import box from ._loop import loop_first from ._stack import Stack -from .console import Console, ConsoleOptions, JustifyMethod, RenderResult, Segment +from .console import Console, ConsoleOptions, JustifyMethod, RenderResult from .containers import Renderables from .jupyter import JupyterMixin from .panel import Panel from .rule import Rule +from .segment import Segment from .style import Style, StyleStack from .syntax import Syntax from .text import Text, TextType @@ -32,7 +33,7 @@ class MarkdownElement: """ return cls() - def on_enter(self, context: "MarkdownContext"): + def on_enter(self, context: "MarkdownContext") -> None: """Called when the node is entered. Args: @@ -107,7 +108,7 @@ class Paragraph(TextElement): justify: JustifyMethod @classmethod - def create(cls, markdown: "Markdown", node) -> "Paragraph": + def create(cls, markdown: "Markdown", node: MarkdownElement) -> "Paragraph": return cls(justify=markdown.justify or "left") def __init__(self, justify: JustifyMethod) -> None: @@ -176,7 +177,7 @@ class CodeBlock(TextElement): ) -> RenderResult: code = str(self.text).rstrip() syntax = Panel( - Syntax(code, self.lexer_name, theme=self.theme), + Syntax(code, self.lexer_name, theme=self.theme, word_wrap=True), border_style="dim", box=box.SQUARE, ) @@ -348,7 +349,7 @@ class MarkdownContext: console: Console, options: ConsoleOptions, style: Style, - inline_code_lexer: str = None, + inline_code_lexer: Optional[str] = None, inline_code_theme: str = "monokai", ) -> None: self.console = console @@ -398,7 +399,7 @@ class Markdown(JupyterMixin): style (Union[str, Style], optional): Optional style to apply to markdown. hyperlinks (bool, optional): Enable hyperlinks. Defaults to ``True``. inline_code_lexer: (str, optional): Lexer to use if inline code highlighting is - enabled. Defaults to "python". + enabled. Defaults to None. inline_code_theme: (Optional[str], optional): Pygments theme for inline code highlighting, or None for no highlighting. Defaults to None. """ @@ -419,17 +420,17 @@ class Markdown(JupyterMixin): self, markup: str, code_theme: str = "monokai", - justify: JustifyMethod = None, + justify: Optional[JustifyMethod] = None, style: Union[str, Style] = "none", hyperlinks: bool = True, - inline_code_lexer: str = None, - inline_code_theme: str = None, + inline_code_lexer: Optional[str] = None, + inline_code_theme: Optional[str] = None, ) -> None: self.markup = markup parser = Parser() self.parsed = parser.parse(markup) self.code_theme = code_theme - self.justify = justify + self.justify: Optional[JustifyMethod] = justify self.style = style self.hyperlinks = hyperlinks self.inline_code_lexer = inline_code_lexer @@ -440,6 +441,7 @@ class Markdown(JupyterMixin): ) -> RenderResult: """Render markdown to the console.""" style = console.get_style(self.style, default="none") + options = options.update(height=None) context = MarkdownContext( console, options, diff --git a/libs/rich/markup.py b/libs/rich/markup.py index 57ed0eaf7..58903f6bb 100644 --- a/libs/rich/markup.py +++ b/libs/rich/markup.py @@ -1,17 +1,22 @@ +from ast import literal_eval +from operator import attrgetter import re -from typing import Iterable, List, Match, NamedTuple, Optional, Tuple, Union +from typing import Callable, Iterable, List, Match, NamedTuple, Optional, Tuple, Union from .errors import MarkupError from .style import Style from .text import Span, Text +from .emoji import EmojiVariant from ._emoji_replace import _emoji_replace RE_TAGS = re.compile( - r"""((\\*)\[([a-z#\/].*?)\])""", + r"""((\\*)\[([a-z#\/@].*?)\])""", re.VERBOSE, ) +RE_HANDLER = re.compile(r"^([\w\.]*?)(\(.*?\))?$") + class Tag(NamedTuple): """A tag in console markup.""" @@ -36,7 +41,14 @@ class Tag(NamedTuple): ) -def escape(markup: str, _escape=re.compile(r"(\\*)(\[[a-z#\/].*?\])").sub) -> str: +_ReStringMatch = Match[str] # regex match object +_ReSubCallable = Callable[[_ReStringMatch], str] # Callable invoked by re.sub +_EscapeSubMethod = Callable[[_ReSubCallable, str], str] # Sub method of a compiled re + + +def escape( + markup: str, _escape: _EscapeSubMethod = re.compile(r"(\\*)(\[[a-z#\/@].*?\])").sub +) -> str: """Escapes text so that it won't be interpreted as markup. Args: @@ -88,7 +100,12 @@ def _parse(markup: str) -> Iterable[Tuple[int, Optional[str], Optional[Tag]]]: yield position, markup[position:], None -def render(markup: str, style: Union[str, Style] = "", emoji: bool = True) -> Text: +def render( + markup: str, + style: Union[str, Style] = "", + emoji: bool = True, + emoji_variant: Optional[EmojiVariant] = None, +) -> Text: """Render console markup in to a Text instance. Args: @@ -103,7 +120,10 @@ def render(markup: str, style: Union[str, Style] = "", emoji: bool = True) -> Te """ emoji_replace = _emoji_replace if "[" not in markup: - return Text(emoji_replace(markup) if emoji else markup, style=style) + return Text( + emoji_replace(markup, default_variant=emoji_variant) if emoji else markup, + style=style, + ) text = Text(style=style) append = text.append normalize = Style.normalize @@ -130,6 +150,7 @@ def render(markup: str, style: Union[str, Style] = "", emoji: bool = True) -> Te 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: @@ -146,7 +167,47 @@ def render(markup: str, style: Union[str, Style] = "", emoji: bool = True) -> Te 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: + handler_name = "" + parameters = open_tag.parameters.strip() + handler_match = RE_HANDLER.match(parameters) + if handler_match is not None: + handler_name, match_parameters = handler_match.groups() + parameters = ( + "()" if match_parameters is None else match_parameters + ) + + try: + meta_params = literal_eval(parameters) + except SyntaxError as error: + raise MarkupError( + f"error parsing {parameters!r} in {open_tag.parameters!r}; {error.msg}" + ) + except Exception as error: + raise MarkupError( + f"error parsing {open_tag.parameters!r}; {error}" + ) from None + + if handler_name: + meta_params = ( + handler_name, + meta_params + if isinstance(meta_params, tuple) + else (meta_params,), + ) + + 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)) @@ -158,24 +219,26 @@ def render(markup: str, style: Union[str, Style] = "", emoji: bool = True) -> Te if style: append_span(_Span(start, text_length, style)) - text.spans = sorted(spans) + text.spans = sorted(spans[::-1], key=attrgetter("start")) return text if __name__ == "__main__": # pragma: no cover - from rich.console import Console - from rich.text import Text + MARKUP = [ + "[red]Hello World[/red]", + "[magenta]Hello [b]World[/b]", + "[bold]Bold[italic] bold and italic [/bold]italic[/italic]", + "Click [link=https://www.willmcgugan.com]here[/link] to visit my Blog", + ":warning-emoji: [bold red blink] DANGER![/]", + ] - console = Console(highlight=False) + from rich.table import Table + from rich import print - console.print("Hello [1], [1,2,3] ['hello']") - console.print("foo") - console.print("Hello [link=https://www.willmcgugan.com]W[b red]o[/]rld[/]!") + grid = Table("Markup", "Result", padding=(0, 1)) - from rich import print + for markup in MARKUP: + grid.add_row(Text(markup), markup) - print(escape("[red]")) - print(escape(r"\[red]")) - print(escape(r"\\[red]")) - print(escape(r"\\\[red]")) + print(grid) diff --git a/libs/rich/measure.py b/libs/rich/measure.py index cd1d3e12f..aea238df9 100644 --- a/libs/rich/measure.py +++ b/libs/rich/measure.py @@ -1,8 +1,8 @@ from operator import itemgetter -from typing import Iterable, NamedTuple, TYPE_CHECKING +from typing import Callable, Iterable, NamedTuple, Optional, TYPE_CHECKING from . import errors -from .protocol import is_renderable +from .protocol import is_renderable, rich_cast if TYPE_CHECKING: from .console import Console, ConsoleOptions, RenderableType @@ -56,7 +56,9 @@ class Measurement(NamedTuple): width = max(0, width) return Measurement(max(minimum, width), max(maximum, width)) - def clamp(self, min_width: int = None, max_width: int = None) -> "Measurement": + def clamp( + self, min_width: Optional[int] = None, max_width: Optional[int] = None + ) -> "Measurement": """Clamp a measurement within the specified range. Args: @@ -95,10 +97,11 @@ class Measurement(NamedTuple): return Measurement(0, 0) if isinstance(renderable, str): renderable = console.render_str(renderable, markup=options.markup) - if hasattr(renderable, "__rich__"): - renderable = renderable.__rich__() # type: ignore + renderable = rich_cast(renderable) if is_renderable(renderable): - get_console_width = getattr(renderable, "__rich_measure__", None) + get_console_width: Optional[ + Callable[["Console", "ConsoleOptions"], "Measurement"] + ] = getattr(renderable, "__rich_measure__", None) if get_console_width is not None: render_width = ( get_console_width(console, options) @@ -126,12 +129,12 @@ def measure_renderables( Args: console (~rich.console.Console): Console instance. + options (~rich.console.ConsoleOptions): Console options. renderables (Iterable[RenderableType]): One or more renderable objects. - max_width (int): The maximum width available. Returns: Measurement: Measurement object containing range of character widths required to - contain all given renderables. + contain all given renderables. """ if not renderables: return Measurement(0, 0) diff --git a/libs/rich/padding.py b/libs/rich/padding.py index da1eb3a8d..1d1f4a553 100644 --- a/libs/rich/padding.py +++ b/libs/rich/padding.py @@ -89,11 +89,13 @@ class Padding(JupyterMixin): + self.right, options.max_width, ) + render_options = options.update_width(width - self.left - self.right) + if render_options.height is not None: + render_options = render_options.update_height( + height=render_options.height - self.top - self.bottom + ) lines = console.render_lines( - self.renderable, - options.update_width(width - self.left - self.right), - style=style, - pad=True, + self.renderable, render_options, style=style, pad=True ) _Segment = Segment diff --git a/libs/rich/pager.py b/libs/rich/pager.py index e226540a3..dbfb973e3 100644 --- a/libs/rich/pager.py +++ b/libs/rich/pager.py @@ -1,4 +1,5 @@ from abc import ABC, abstractmethod +from typing import Any, Callable class Pager(ABC): @@ -16,7 +17,8 @@ class Pager(ABC): class SystemPager(Pager): """Uses the pager installed on the system.""" - _pager = lambda self, content: __import__("pydoc").pager(content) + def _pager(self, content: str) -> Any: # pragma: no cover + return __import__("pydoc").pager(content) def show(self, content: str) -> None: """Use the same pager used by pydoc.""" diff --git a/libs/rich/panel.py b/libs/rich/panel.py index 71ca9ebc0..151fe5f01 100644 --- a/libs/rich/panel.py +++ b/libs/rich/panel.py @@ -40,8 +40,10 @@ class Panel(JupyterMixin): renderable: "RenderableType", box: Box = ROUNDED, *, - title: TextType = None, + title: Optional[TextType] = None, title_align: AlignMethod = "center", + subtitle: Optional[TextType] = None, + subtitle_align: AlignMethod = "center", safe_box: Optional[bool] = None, expand: bool = True, style: StyleType = "none", @@ -54,7 +56,9 @@ class Panel(JupyterMixin): self.renderable = renderable self.box = box self.title = title - self.title_align = title_align + self.title_align: AlignMethod = title_align + self.subtitle = subtitle + self.subtitle_align = subtitle_align self.safe_box = safe_box self.expand = expand self.style = style @@ -70,20 +74,24 @@ class Panel(JupyterMixin): renderable: "RenderableType", box: Box = ROUNDED, *, - title: TextType = None, + title: Optional[TextType] = None, title_align: AlignMethod = "center", + subtitle: Optional[TextType] = None, + subtitle_align: AlignMethod = "center", safe_box: Optional[bool] = None, style: StyleType = "none", border_style: StyleType = "none", width: Optional[int] = None, padding: PaddingDimensions = (0, 1), - ): + ) -> "Panel": """An alternative constructor that sets expand=False.""" return cls( renderable, box, title=title, title_align=title_align, + subtitle=subtitle, + subtitle_align=subtitle_align, safe_box=safe_box, style=style, border_style=border_style, @@ -108,6 +116,22 @@ class Panel(JupyterMixin): return title_text return None + @property + def _subtitle(self) -> Optional[Text]: + if self.subtitle: + subtitle_text = ( + Text.from_markup(self.subtitle) + if isinstance(self.subtitle, str) + else self.subtitle.copy() + ) + subtitle_text.end = "" + subtitle_text.plain = subtitle_text.plain.replace("\n", " ") + subtitle_text.no_wrap = True + subtitle_text.expand_tabs() + subtitle_text.pad(1) + return subtitle_text + return None + def __rich_console__( self, console: "Console", options: "ConsoleOptions" ) -> "RenderResult": @@ -123,7 +147,7 @@ class Panel(JupyterMixin): else min(options.max_width, self.width) ) - safe_box: bool = console.safe_box if self.safe_box is None else self.safe_box # type: ignore + safe_box: bool = console.safe_box if self.safe_box is None else self.safe_box box = self.box.substitute(options, safe=safe_box) title_text = self._title @@ -133,8 +157,8 @@ class Panel(JupyterMixin): child_width = ( width - 2 if self.expand - else Measurement.get( - console, options.update_width(width - 2), renderable + else console.measure( + renderable, options=options.update_width(width - 2) ).maximum ) child_height = self.height or options.height or None @@ -168,7 +192,19 @@ class Panel(JupyterMixin): yield from line yield line_end yield new_line - yield Segment(box.get_bottom([width - 2]), border_style) + + subtitle_text = self._subtitle + if subtitle_text is not None: + subtitle_text.style = border_style + + if subtitle_text is None or width <= 4: + yield Segment(box.get_bottom([width - 2]), border_style) + else: + subtitle_text.align(self.subtitle_align, width - 4, character=box.bottom) + yield Segment(box.bottom_left + box.bottom, border_style) + yield from console.render(subtitle_text) + yield Segment(box.bottom + box.bottom_right, border_style) + yield new_line def __rich_measure__( diff --git a/libs/rich/pretty.py b/libs/rich/pretty.py index 7903b2cc2..57e743df4 100644 --- a/libs/rich/pretty.py +++ b/libs/rich/pretty.py @@ -1,24 +1,36 @@ import builtins +import dataclasses +import inspect import os +import re import sys from array import array -from collections import Counter, defaultdict, deque +from collections import Counter, UserDict, UserList, defaultdict, deque from dataclasses import dataclass, fields, is_dataclass +from inspect import isclass from itertools import islice +from types import MappingProxyType from typing import ( TYPE_CHECKING, Any, Callable, + DefaultDict, Dict, Iterable, List, Optional, Set, - Union, Tuple, + Union, ) -from rich.highlighter import ReprHighlighter +from rich.repr import RichReprResult + +try: + import attr as _attr_module +except ImportError: # pragma: no cover + _attr_module = None # type: ignore + from . import get_console from ._loop import loop_last @@ -41,13 +53,104 @@ if TYPE_CHECKING: ) +def _is_attr_object(obj: Any) -> bool: + """Check if an object was created with attrs module.""" + return _attr_module is not None and _attr_module.has(type(obj)) + + +def _get_attr_fields(obj: Any) -> Iterable["_attr_module.Attribute[Any]"]: + """Get fields for an attrs object.""" + return _attr_module.fields(type(obj)) if _attr_module is not None else [] + + +def _is_dataclass_repr(obj: object) -> bool: + """Check if an instance of a dataclass contains the default repr. + + Args: + obj (object): A dataclass instance. + + Returns: + bool: True if the default repr is used, False if there is a custom repr. + """ + # Digging in to a lot of internals here + # Catching all exceptions in case something is missing on a non CPython implementation + try: + return obj.__repr__.__code__.co_filename == dataclasses.__file__ + except Exception: # pragma: no coverage + return False + + +def _ipy_display_hook( + value: Any, + console: Optional["Console"] = None, + overflow: "OverflowMethod" = "ignore", + crop: bool = False, + indent_guides: bool = False, + max_length: Optional[int] = None, + max_string: Optional[int] = None, + expand_all: bool = False, +) -> None: + from .console import ConsoleRenderable # needed here to prevent circular import + + # always skip rich generated jupyter renderables or None values + if isinstance(value, JupyterRenderable) or value is None: + return + + console = console or get_console() + if console.is_jupyter: + # Delegate rendering to IPython if the object (and IPython) supports it + # https://ipython.readthedocs.io/en/stable/config/integrating.html#rich-display + ipython_repr_methods = [ + "_repr_html_", + "_repr_markdown_", + "_repr_json_", + "_repr_latex_", + "_repr_jpeg_", + "_repr_png_", + "_repr_svg_", + "_repr_mimebundle_", + ] + for repr_method in ipython_repr_methods: + method = getattr(value, repr_method, None) + if inspect.ismethod(method): + # Calling the method ourselves isn't ideal. The interface for the `_repr_*_` methods + # specifies that if they return None, then they should not be rendered + # by the notebook. + try: + repr_result = method() + except Exception: + continue # If the method raises, treat it as if it doesn't exist, try any others + if repr_result is not None: + return # Delegate rendering to IPython + + # certain renderables should start on a new line + if isinstance(value, ConsoleRenderable): + console.line() + + console.print( + value + if isinstance(value, RichRenderable) + else Pretty( + value, + overflow=overflow, + indent_guides=indent_guides, + max_length=max_length, + max_string=max_string, + expand_all=expand_all, + margin=12, + ), + crop=crop, + new_line_start=True, + ) + + def install( - console: "Console" = None, + console: Optional["Console"] = None, overflow: "OverflowMethod" = "ignore", crop: bool = False, indent_guides: bool = False, - max_length: int = None, - max_string: int = None, + max_length: Optional[int] = None, + max_string: Optional[int] = None, expand_all: bool = False, ) -> None: """Install automatic pretty printing in the Python REPL. @@ -60,12 +163,11 @@ def install( max_length (int, optional): Maximum length of containers before abbreviating, or None for no abbreviation. Defaults to None. max_string (int, optional): Maximum length of string before truncating, or None to disable. Defaults to None. - expand_all (bool, optional): Expand all containers. Defaults to False + expand_all (bool, optional): Expand all containers. Defaults to False. + max_frames (int): Maximum number of frames to show in a traceback, 0 for no maximum. Defaults to 100. """ from rich import get_console - from .console import ConsoleRenderable # needed here to prevent circular import - console = console or get_console() assert console is not None @@ -89,44 +191,29 @@ def install( ) builtins._ = value # type: ignore - def ipy_display_hook(value: Any) -> None: # pragma: no cover - assert console is not None - # always skip rich generated jupyter renderables or None values - if isinstance(value, JupyterRenderable) or value is None: - return - # on jupyter rich display, if using one of the special representations dont use rich - if console.is_jupyter and any(attr.startswith("_repr_") for attr in dir(value)): - return - - if hasattr(value, "_repr_mimebundle_"): - return - - # certain renderables should start on a new line - if isinstance(value, ConsoleRenderable): - console.line() - - console.print( - value - if isinstance(value, RichRenderable) - else Pretty( - value, - overflow=overflow, - indent_guides=indent_guides, - max_length=max_length, - max_string=max_string, - expand_all=expand_all, - margin=12, - ), - crop=crop, - ) - try: # pragma: no cover ip = get_ipython() # type: ignore from IPython.core.formatters import BaseFormatter + class RichFormatter(BaseFormatter): # type: ignore + pprint: bool = True + + def __call__(self, value: Any) -> Any: + if self.pprint: + return _ipy_display_hook( + value, + console=get_console(), + overflow=overflow, + indent_guides=indent_guides, + max_length=max_length, + max_string=max_string, + expand_all=expand_all, + ) + else: + return repr(value) + # replace plain text formatter with rich formatter - rich_formatter = BaseFormatter() - rich_formatter.for_type(object, func=ipy_display_hook) + rich_formatter = RichFormatter() ip.display_formatter.formatters["text/plain"] = rich_formatter except Exception: sys.displayhook = display_hook @@ -146,6 +233,7 @@ class Pretty(JupyterMixin): max_length (int, optional): Maximum length of containers before abbreviating, or None for no abbreviation. Defaults to None. max_string (int, optional): Maximum length of string before truncating, or None to disable. Defaults to None. + max_depth (int, optional): Maximum depth of nested data structures, or None for no maximum. Defaults to None. expand_all (bool, optional): Expand all containers. Defaults to False. margin (int, optional): Subtrace a margin from width to force containers to expand earlier. Defaults to 0. insert_line (bool, optional): Insert a new line if the output has multiple new lines. Defaults to False. @@ -154,15 +242,16 @@ class Pretty(JupyterMixin): def __init__( self, _object: Any, - highlighter: "HighlighterType" = None, + highlighter: Optional["HighlighterType"] = None, *, indent_size: int = 4, - justify: "JustifyMethod" = None, + justify: Optional["JustifyMethod"] = None, overflow: Optional["OverflowMethod"] = None, no_wrap: Optional[bool] = False, indent_guides: bool = False, - max_length: int = None, - max_string: int = None, + max_length: Optional[int] = None, + max_string: Optional[int] = None, + max_depth: Optional[int] = None, expand_all: bool = False, margin: int = 0, insert_line: bool = False, @@ -170,12 +259,13 @@ class Pretty(JupyterMixin): self._object = _object self.highlighter = highlighter or ReprHighlighter() self.indent_size = indent_size - self.justify = justify - self.overflow = overflow + self.justify: Optional["JustifyMethod"] = justify + self.overflow: Optional["OverflowMethod"] = overflow self.no_wrap = no_wrap self.indent_guides = indent_guides self.max_length = max_length self.max_string = max_string + self.max_depth = max_depth self.expand_all = expand_all self.margin = margin self.insert_line = insert_line @@ -189,6 +279,7 @@ class Pretty(JupyterMixin): indent_size=self.indent_size, max_length=self.max_length, max_string=self.max_string, + max_depth=self.max_depth, expand_all=self.expand_all, ) pretty_text = Text( @@ -230,7 +321,7 @@ class Pretty(JupyterMixin): return Measurement(text_width, text_width) -def _get_braces_for_defaultdict(_object: defaultdict) -> Tuple[str, str, str]: +def _get_braces_for_defaultdict(_object: DefaultDict[Any, Any]) -> Tuple[str, str, str]: return ( f"defaultdict({_object.default_factory!r}, {{", "})", @@ -238,7 +329,7 @@ def _get_braces_for_defaultdict(_object: defaultdict) -> Tuple[str, str, str]: ) -def _get_braces_for_array(_object: array) -> Tuple[str, str, str]: +def _get_braces_for_array(_object: "array[Any]") -> Tuple[str, str, str]: return (f"array({_object.typecode!r}, [", "])", "array({_object.typecode!r})") @@ -249,22 +340,26 @@ _BRACES: Dict[type, Callable[[Any], Tuple[str, str, str]]] = { Counter: lambda _object: ("Counter({", "})", "Counter()"), deque: lambda _object: ("deque([", "])", "deque()"), dict: lambda _object: ("{", "}", "{}"), + UserDict: lambda _object: ("{", "}", "{}"), frozenset: lambda _object: ("frozenset({", "})", "frozenset()"), list: lambda _object: ("[", "]", "[]"), + UserList: lambda _object: ("[", "]", "[]"), set: lambda _object: ("{", "}", "set()"), tuple: lambda _object: ("(", ")", "()"), + MappingProxyType: lambda _object: ("mappingproxy({", "})", "mappingproxy({})"), } _CONTAINERS = tuple(_BRACES.keys()) -_MAPPING_CONTAINERS = (dict, os._Environ) +_MAPPING_CONTAINERS = (dict, os._Environ, MappingProxyType, UserDict) 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 @@ -280,11 +375,7 @@ class Node: is_tuple: bool = False children: Optional[List["Node"]] = None key_separator = ": " - - @property - def separator(self) -> str: - """Get separator between items.""" - return "" if self.last else "," + separator: str = ", " def iter_tokens(self) -> Iterable[str]: """Generate tokens for this node.""" @@ -303,7 +394,7 @@ class Node: for child in self.children: yield from child.iter_tokens() if not child.last: - yield ", " + yield self.separator yield self.close_brace else: yield self.empty @@ -359,12 +450,14 @@ class Node: class _Line: """A line in repr output.""" + parent: Optional["_Line"] = None is_root: bool = False node: Optional[Node] = None text: str = "" suffix: str = "" whitespace: str = "" expanded: bool = False + last: bool = False @property def expandable(self) -> bool: @@ -386,34 +479,47 @@ class _Line: whitespace = self.whitespace assert node.children if node.key_repr: - yield _Line( + new_line = yield _Line( text=f"{node.key_repr}{node.key_separator}{node.open_brace}", whitespace=whitespace, ) else: - yield _Line(text=node.open_brace, whitespace=whitespace) + new_line = yield _Line(text=node.open_brace, whitespace=whitespace) child_whitespace = self.whitespace + " " * indent_size tuple_of_one = node.is_tuple and len(node.children) == 1 - for child in node.children: - separator = "," if tuple_of_one else child.separator + for last, child in loop_last(node.children): + separator = "," if tuple_of_one else node.separator line = _Line( + parent=new_line, node=child, whitespace=child_whitespace, suffix=separator, + last=last and not tuple_of_one, ) yield line yield _Line( text=node.close_brace, whitespace=whitespace, - suffix="," if (tuple_of_one and not self.is_root) else node.separator, + suffix=self.suffix, + last=self.last, ) def __str__(self) -> str: - return f"{self.whitespace}{self.text}{self.node or ''}{self.suffix}" + if self.last: + return f"{self.whitespace}{self.text}{self.node or ''}" + else: + return ( + f"{self.whitespace}{self.text}{self.node or ''}{self.suffix.rstrip()}" + ) -def traverse(_object: Any, max_length: int = None, max_string: int = None) -> Node: +def traverse( + _object: Any, + max_length: Optional[int] = None, + max_string: Optional[int] = None, + max_depth: Optional[int] = None, +) -> Node: """Traverse object and generate a tree. Args: @@ -422,6 +528,8 @@ def traverse(_object: Any, max_length: int = None, max_string: int = None) -> No Defaults to None. max_string (int, optional): Maximum length of string before truncating, or None to disable truncating. Defaults to None. + max_depth (int, optional): Maximum depth of data structures, or None for no maximum. + Defaults to None. Returns: Node: The root of a tree structure which can be used to render a pretty repr. @@ -440,20 +548,22 @@ def traverse(_object: Any, max_length: int = None, max_string: int = None) -> No try: obj_repr = repr(obj) except Exception as error: - obj_repr = f"<repr-error '{error}'>" + obj_repr = f"<repr-error {str(error)!r}>" return obj_repr visited_ids: Set[int] = set() push_visited = visited_ids.add pop_visited = visited_ids.remove - def _traverse(obj: Any, root: bool = False) -> Node: + def _traverse(obj: Any, root: bool = False, depth: int = 0) -> Node: """Walk the object depth first.""" + obj_type = type(obj) py_version = (sys.version_info.major, sys.version_info.minor) children: List[Node] + reached_max_depth = max_depth is not None and depth >= max_depth - def iter_rich_args(rich_args) -> Iterable[Union[Any, Tuple[str, Any]]]: + def iter_rich_args(rich_args: Any) -> Iterable[Union[Any, Tuple[str, Any]]]: for arg in rich_args: if isinstance(arg, tuple): if len(arg) == 3: @@ -469,31 +579,109 @@ def traverse(_object: Any, max_length: int = None, max_string: int = None) -> No else: yield arg - if hasattr(obj, "__rich_repr__"): - args = list(iter_rich_args(obj.__rich_repr__())) + try: + fake_attributes = hasattr( + obj, "awehoi234_wdfjwljet234_234wdfoijsdfmmnxpi492" + ) + except Exception: + fake_attributes = False + + rich_repr_result: Optional[RichReprResult] = None + if not fake_attributes: + try: + if hasattr(obj, "__rich_repr__") and not isclass(obj): + rich_repr_result = obj.__rich_repr__() + except Exception: + pass + + if rich_repr_result is not None: + angular = getattr(obj.__rich_repr__, "angular", False) + args = list(iter_rich_args(rich_repr_result)) + class_name = obj.__class__.__name__ if args: children = [] append = children.append + + if reached_max_depth: + node = Node(value_repr=f"...") + else: + if angular: + node = Node( + open_brace=f"<{class_name} ", + close_brace=">", + children=children, + last=root, + separator=" ", + ) + else: + node = Node( + open_brace=f"{class_name}(", + close_brace=")", + children=children, + last=root, + ) + for last, arg in loop_last(args): + if isinstance(arg, tuple): + key, child = arg + child_node = _traverse(child, depth=depth + 1) + child_node.last = last + child_node.key_repr = key + child_node.key_separator = "=" + append(child_node) + else: + child_node = _traverse(arg, depth=depth + 1) + child_node.last = last + append(child_node) + else: node = Node( - open_brace=f"{obj.__class__.__name__}(", - close_brace=")", - children=children, + value_repr=f"<{class_name}>" if angular else f"{class_name}()", + children=[], last=root, ) - for last, arg in loop_last(args): - if isinstance(arg, tuple): - key, child = arg - child_node = _traverse(child) - child_node.last = last - child_node.key_repr = key + elif _is_attr_object(obj) and not fake_attributes: + children = [] + append = children.append + + attr_fields = _get_attr_fields(obj) + if attr_fields: + if reached_max_depth: + node = Node(value_repr=f"...") + else: + node = Node( + open_brace=f"{obj.__class__.__name__}(", + close_brace=")", + children=children, + last=root, + ) + + def iter_attrs() -> Iterable[ + Tuple[str, Any, Optional[Callable[[Any], str]]] + ]: + """Iterate over attr fields and values.""" + for attr in attr_fields: + if attr.repr: + try: + value = getattr(obj, attr.name) + except Exception as error: + # Can happen, albeit rarely + yield (attr.name, error, None) + else: + yield ( + attr.name, + value, + attr.repr if callable(attr.repr) else None, + ) + + for last, (name, value, repr_callable) in loop_last(iter_attrs()): + if repr_callable: + child_node = Node(value_repr=str(repr_callable(value))) + else: + child_node = _traverse(value, depth=depth + 1) child_node.last = last + child_node.key_repr = name child_node.key_separator = "=" append(child_node) - else: - child_node = _traverse(arg) - child_node.last = last - append(child_node) else: node = Node( value_repr=f"{obj.__class__.__name__}()", children=[], last=root @@ -502,9 +690,8 @@ def traverse(_object: Any, max_length: int = None, max_string: int = None) -> No elif ( is_dataclass(obj) and not isinstance(obj, type) - and ( - "__create_fn__" in obj.__repr__.__qualname__ or py_version == (3, 6) - ) # Check if __repr__ wasn't overriden + and not fake_attributes + and (_is_dataclass_repr(obj) or py_version == (3, 6)) ): obj_id = id(obj) if obj_id in visited_ids: @@ -514,24 +701,33 @@ def traverse(_object: Any, max_length: int = None, max_string: int = None) -> No children = [] append = children.append - node = Node( - open_brace=f"{obj.__class__.__name__}(", - close_brace=")", - children=children, - last=root, - ) + if reached_max_depth: + node = Node(value_repr=f"...") + else: + node = Node( + open_brace=f"{obj.__class__.__name__}(", + close_brace=")", + children=children, + last=root, + ) - for last, field in loop_last(fields(obj)): - if field.repr: - child_node = _traverse(getattr(obj, field.name)) + for last, field in loop_last( + field for field in fields(obj) if field.repr + ): + child_node = _traverse(getattr(obj, field.name), depth=depth + 1) child_node.key_repr = field.name child_node.last = last child_node.key_separator = "=" append(child_node) - pop_visited(obj_id) + pop_visited(obj_id) + + elif isinstance(obj, _CONTAINERS): + for container_type in _CONTAINERS: + if isinstance(obj, container_type): + obj_type = container_type + break - elif obj_type in _CONTAINERS: obj_id = id(obj) if obj_id in visited_ids: # Recursion detected @@ -540,7 +736,11 @@ def traverse(_object: Any, max_length: int = None, max_string: int = None) -> No open_brace, close_brace, empty = _BRACES[obj_type](obj) - if obj: + if reached_max_depth: + node = Node(value_repr=f"...", last=root) + elif obj_type.__repr__ != type(obj).__repr__: + node = Node(value_repr=to_repr(obj), last=root) + elif obj: children = [] node = Node( open_brace=open_brace, @@ -557,7 +757,7 @@ def traverse(_object: Any, max_length: int = None, max_string: int = None) -> No if max_length is not None: iter_items = islice(iter_items, max_length) for index, (key, child) in enumerate(iter_items): - child_node = _traverse(child) + child_node = _traverse(child, depth=depth + 1) child_node.key_repr = to_repr(key) child_node.last = index == last_item_index append(child_node) @@ -566,7 +766,7 @@ def traverse(_object: Any, max_length: int = None, max_string: int = None) -> No if max_length is not None: iter_values = islice(iter_values, max_length) for index, child in enumerate(iter_values): - child_node = _traverse(child) + child_node = _traverse(child, depth=depth + 1) child_node.last = index == last_item_index append(child_node) if max_length is not None and num_items > max_length: @@ -589,8 +789,9 @@ def pretty_repr( *, max_width: int = 80, indent_size: int = 4, - max_length: int = None, - max_string: int = None, + max_length: Optional[int] = None, + max_string: Optional[int] = None, + max_depth: Optional[int] = None, expand_all: bool = False, ) -> str: """Prettify repr string by expanding on to new lines to fit within a given width. @@ -603,6 +804,8 @@ def pretty_repr( Defaults to None. max_string (int, optional): Maximum length of string before truncating, or None to disable truncating. Defaults to None. + max_depth (int, optional): Maximum depth of nested data structure, or None for no depth. + Defaults to None. expand_all (bool, optional): Expand all containers regardless of available width. Defaults to False. Returns: @@ -612,7 +815,9 @@ def pretty_repr( if isinstance(_object, Node): node = _object else: - node = traverse(_object, max_length=max_length, max_string=max_string) + node = traverse( + _object, max_length=max_length, max_string=max_string, max_depth=max_depth + ) repr_str = node.render( max_width=max_width, indent_size=indent_size, expand_all=expand_all ) @@ -622,12 +827,13 @@ def pretty_repr( def pprint( _object: Any, *, - console: "Console" = None, + console: Optional["Console"] = None, indent_guides: bool = True, - max_length: int = None, - max_string: int = None, + max_length: Optional[int] = None, + max_string: Optional[int] = None, + max_depth: Optional[int] = None, expand_all: bool = False, -): +) -> None: """A convenience function for pretty printing. Args: @@ -636,6 +842,7 @@ def pprint( max_length (int, optional): Maximum length of containers before abbreviating, or None for no abbreviation. Defaults to None. max_string (int, optional): Maximum length of strings before truncating, or None to disable. Defaults to None. + max_depth (int, optional): Maximum depth for nested data structures, or None for unlimited depth. Defaults to None. indent_guides (bool, optional): Enable indentation guides. Defaults to True. expand_all (bool, optional): Expand all containers. Defaults to False. """ @@ -645,6 +852,7 @@ def pprint( _object, max_length=max_length, max_string=max_string, + max_depth=max_depth, indent_guides=indent_guides, expand_all=expand_all, overflow="ignore", @@ -656,8 +864,9 @@ def pprint( if __name__ == "__main__": # pragma: no cover class BrokenRepr: - def __repr__(self): + def __repr__(self) -> str: 1 / 0 + return "this will fail" d = defaultdict(int) d["foo"] = 5 diff --git a/libs/rich/progress.py b/libs/rich/progress.py index 12545ebf1..1f670db43 100644 --- a/libs/rich/progress.py +++ b/libs/rich/progress.py @@ -1,4 +1,3 @@ -import sys from abc import ABC, abstractmethod from collections import deque from collections.abc import Sized @@ -6,6 +5,7 @@ from dataclasses import dataclass, field from datetime import timedelta from math import ceil from threading import Event, RLock, Thread +from types import TracebackType from typing import ( Any, Callable, @@ -18,19 +18,15 @@ from typing import ( Optional, Sequence, Tuple, + Type, TypeVar, Union, ) from . import filesize, get_console -from .console import ( - Console, - JustifyMethod, - RenderableType, - RenderGroup, -) -from .jupyter import JupyterMixin +from .console import Console, JustifyMethod, RenderableType, Group from .highlighter import Highlighter +from .jupyter import JupyterMixin from .live import Live from .progress_bar import ProgressBar from .spinner import Spinner @@ -75,19 +71,24 @@ class _TrackThread(Thread): self.start() return self - def __exit__(self, exc_type, exc_val, exc_tb) -> None: + def __exit__( + self, + exc_type: Optional[Type[BaseException]], + exc_val: Optional[BaseException], + exc_tb: Optional[TracebackType], + ) -> None: self.done.set() self.join() def track( sequence: Union[Sequence[ProgressType], Iterable[ProgressType]], - description="Working...", + description: str = "Working...", total: Optional[float] = None, - auto_refresh=True, + auto_refresh: bool = True, console: Optional[Console] = None, transient: bool = False, - get_time: Callable[[], float] = None, + get_time: Optional[Callable[[], float]] = None, refresh_per_second: float = 10, style: StyleType = "bar.back", complete_style: StyleType = "bar.complete", @@ -153,7 +154,7 @@ class ProgressColumn(ABC): max_refresh: Optional[float] = None - def __init__(self, table_column: Column = None) -> None: + def __init__(self, table_column: Optional[Column] = None) -> None: self._table_column = table_column self._renderable_cache: Dict[TaskID, Tuple[float, RenderableType]] = {} self._update_time: Optional[float] = None @@ -171,7 +172,7 @@ class ProgressColumn(ABC): Returns: RenderableType: Anything renderable (including str). """ - current_time = task.get_time() # type: ignore + current_time = task.get_time() if self.max_refresh is not None and not task.completed: try: timestamp, renderable = self._renderable_cache[task.id] @@ -197,7 +198,9 @@ class RenderableColumn(ProgressColumn): renderable (RenderableType, optional): Any renderable. Defaults to empty string. """ - def __init__(self, renderable: RenderableType = "", *, table_column: Column = None): + def __init__( + self, renderable: RenderableType = "", *, table_column: Optional[Column] = None + ): self.renderable = renderable super().__init__(table_column=table_column) @@ -221,7 +224,7 @@ class SpinnerColumn(ProgressColumn): style: Optional[StyleType] = "progress.spinner", speed: float = 1.0, finished_text: TextType = " ", - table_column: Column = None, + table_column: Optional[Column] = None, ): self.spinner = Spinner(spinner_name, style=style, speed=speed) self.finished_text = ( @@ -236,7 +239,7 @@ class SpinnerColumn(ProgressColumn): spinner_name: str, spinner_style: Optional[StyleType] = "progress.spinner", speed: float = 1.0, - ): + ) -> None: """Set a new spinner. Args: @@ -246,7 +249,7 @@ class SpinnerColumn(ProgressColumn): """ self.spinner = Spinner(spinner_name, style=spinner_style, speed=speed) - def render(self, task: "Task") -> Text: + def render(self, task: "Task") -> RenderableType: text = ( self.finished_text if task.finished @@ -264,11 +267,11 @@ class TextColumn(ProgressColumn): style: StyleType = "none", justify: JustifyMethod = "left", markup: bool = True, - highlighter: Highlighter = None, - table_column: Column = None, + highlighter: Optional[Highlighter] = None, + table_column: Optional[Column] = None, ) -> None: self.text_format = text_format - self.justify = justify + self.justify: JustifyMethod = justify self.style = style self.markup = markup self.highlighter = highlighter @@ -303,7 +306,7 @@ class BarColumn(ProgressColumn): complete_style: StyleType = "bar.complete", finished_style: StyleType = "bar.finished", pulse_style: StyleType = "bar.pulse", - table_column: Column = None, + table_column: Optional[Column] = None, ) -> None: self.bar_width = bar_width self.style = style @@ -379,7 +382,9 @@ class DownloadColumn(ProgressColumn): binary_units (bool, optional): Use binary units, KiB, MiB etc. Defaults to False. """ - def __init__(self, binary_units: bool = False, table_column: Column = None) -> None: + def __init__( + self, binary_units: bool = False, table_column: Optional[Column] = None + ) -> None: self.binary_units = binary_units super().__init__(table_column=table_column) @@ -467,7 +472,7 @@ class Task: """Optional[float]: Time this task was stopped, or None if not stopped.""" finished_speed: Optional[float] = None - """Optional[float]: The last speed for a finshed task.""" + """Optional[float]: The last speed for a finished task.""" _progress: Deque[ProgressSample] = field( default_factory=deque, init=False, repr=False @@ -478,7 +483,7 @@ class Task: def get_time(self) -> float: """float: Get the current time, in seconds.""" - return self._get_time() # type: ignore + return self._get_time() @property def started(self) -> bool: @@ -568,19 +573,19 @@ class Progress(JupyterMixin): def __init__( self, *columns: Union[str, ProgressColumn], - console: Console = None, + console: Optional[Console] = None, auto_refresh: bool = True, refresh_per_second: float = 10, speed_estimate_period: float = 30.0, transient: bool = False, redirect_stdout: bool = True, redirect_stderr: bool = True, - get_time: GetTimeCallable = None, + get_time: Optional[GetTimeCallable] = None, disable: bool = False, expand: bool = False, ) -> None: assert ( - refresh_per_second is None or refresh_per_second > 0 # type: ignore + refresh_per_second is None or refresh_per_second > 0 ), "refresh_per_second must be > 0" self._lock = RLock() self.columns = columns or ( @@ -640,12 +645,19 @@ class Progress(JupyterMixin): def stop(self) -> None: """Stop the progress display.""" self.live.stop() + if not self.console.is_interactive: + self.console.print() def __enter__(self) -> "Progress": self.start() return self - def __exit__(self, exc_type, exc_val, exc_tb) -> None: + def __exit__( + self, + exc_type: Optional[Type[BaseException]], + exc_val: Optional[BaseException], + exc_tb: Optional[TracebackType], + ) -> None: self.stop() def track( @@ -653,7 +665,7 @@ class Progress(JupyterMixin): sequence: Union[Iterable[ProgressType], Sequence[ProgressType]], total: Optional[float] = None, task_id: Optional[TaskID] = None, - description="Working...", + description: str = "Working...", update_period: float = 0.1, ) -> Iterable[ProgressType]: """Track progress by iterating over a sequence. @@ -731,10 +743,10 @@ class Progress(JupyterMixin): task_id: TaskID, *, total: Optional[float] = None, - completed: float = None, - advance: float = None, - description: str = None, - visible: bool = None, + completed: Optional[float] = None, + advance: Optional[float] = None, + description: Optional[str] = None, + visible: Optional[bool] = None, refresh: bool = False, **fields: Any, ) -> None: @@ -754,7 +766,7 @@ class Progress(JupyterMixin): task = self._tasks[task_id] completed_start = task.completed - if total is not None: + if total is not None and total != task.total: task.total = total task._reset() if advance is not None: @@ -855,7 +867,7 @@ class Progress(JupyterMixin): def get_renderable(self) -> RenderableType: """Get a renderable for the progress display.""" - renderable = RenderGroup(*self.get_renderables()) + renderable = Group(*self.get_renderables()) return renderable def get_renderables(self) -> Iterable[RenderableType]: diff --git a/libs/rich/progress_bar.py b/libs/rich/progress_bar.py index bf4f3b532..1797b5f78 100644 --- a/libs/rich/progress_bar.py +++ b/libs/rich/progress_bar.py @@ -34,13 +34,13 @@ class ProgressBar(JupyterMixin): self, total: float = 100.0, completed: float = 0, - width: int = None, + width: Optional[int] = None, pulse: bool = False, style: StyleType = "bar.back", complete_style: StyleType = "bar.complete", finished_style: StyleType = "bar.finished", pulse_style: StyleType = "bar.pulse", - animation_time: float = None, + animation_time: Optional[float] = None, ): self.total = total self.completed = completed @@ -111,7 +111,7 @@ class ProgressBar(JupyterMixin): append(_Segment(bar, _Style(color=from_triplet(color)))) return segments - def update(self, completed: float, total: float = None) -> None: + def update(self, completed: float, total: Optional[float] = None) -> None: """Update progress with new values. Args: diff --git a/libs/rich/prompt.py b/libs/rich/prompt.py index 7db5d2db7..c56071887 100644 --- a/libs/rich/prompt.py +++ b/libs/rich/prompt.py @@ -54,9 +54,9 @@ class PromptBase(Generic[PromptType]): self, prompt: TextType = "", *, - console: Console = None, + console: Optional[Console] = None, password: bool = False, - choices: List[str] = None, + choices: Optional[List[str]] = None, show_default: bool = True, show_choices: bool = True, ) -> None: @@ -78,13 +78,13 @@ class PromptBase(Generic[PromptType]): cls, prompt: TextType = "", *, - console: Console = None, + console: Optional[Console] = None, password: bool = False, - choices: List[str] = None, + choices: Optional[List[str]] = None, show_default: bool = True, show_choices: bool = True, default: DefaultType, - stream: TextIO = None, + stream: Optional[TextIO] = None, ) -> Union[DefaultType, PromptType]: ... @@ -94,12 +94,12 @@ class PromptBase(Generic[PromptType]): cls, prompt: TextType = "", *, - console: Console = None, + console: Optional[Console] = None, password: bool = False, - choices: List[str] = None, + choices: Optional[List[str]] = None, show_default: bool = True, show_choices: bool = True, - stream: TextIO = None, + stream: Optional[TextIO] = None, ) -> PromptType: ... @@ -108,13 +108,13 @@ class PromptBase(Generic[PromptType]): cls, prompt: TextType = "", *, - console: Console = None, + console: Optional[Console] = None, password: bool = False, - choices: List[str] = None, + choices: Optional[List[str]] = None, show_default: bool = True, show_choices: bool = True, default: Any = ..., - stream: TextIO = None, + stream: Optional[TextIO] = None, ) -> Any: """Shortcut to construct and run a prompt loop and return the result. @@ -188,7 +188,7 @@ class PromptBase(Generic[PromptType]): console: Console, prompt: TextType, password: bool, - stream: TextIO = None, + stream: Optional[TextIO] = None, ) -> str: """Get input from user. @@ -235,7 +235,7 @@ class PromptBase(Generic[PromptType]): if self.choices is not None and not self.check_choice(value): raise InvalidResponse(self.illegal_choice_message) - return return_value + return return_value # type: ignore def on_validate_error(self, value: str, error: InvalidResponse) -> None: """Called to handle validation error. @@ -250,16 +250,16 @@ class PromptBase(Generic[PromptType]): """Hook to display something before the prompt.""" @overload - def __call__(self, *, stream: TextIO = None) -> PromptType: + def __call__(self, *, stream: Optional[TextIO] = None) -> PromptType: ... @overload def __call__( - self, *, default: DefaultType, stream: TextIO = None + self, *, default: DefaultType, stream: Optional[TextIO] = None ) -> Union[PromptType, DefaultType]: ... - def __call__(self, *, default: Any = ..., stream: TextIO = None) -> Any: + def __call__(self, *, default: Any = ..., stream: Optional[TextIO] = None) -> Any: """Run the prompt loop. Args: @@ -330,11 +330,10 @@ class Confirm(PromptBase[bool]): response_type = bool validate_error_message = "[prompt.invalid]Please enter Y or N" - choices = ["y", "n"] + choices: List[str] = ["y", "n"] def render_default(self, default: DefaultType) -> Text: """Render the default as (y) or (n) rather than True/False.""" - assert self.choices is not None yes, no = self.choices return Text(f"({yes})" if default else f"({no})", style="prompt.default") @@ -343,7 +342,6 @@ class Confirm(PromptBase[bool]): value = value.strip().lower() if value not in self.choices: raise InvalidResponse(self.validate_error_message) - assert self.choices is not None return value == self.choices[0] diff --git a/libs/rich/protocol.py b/libs/rich/protocol.py index 7081cf54e..ff69cc8b0 100644 --- a/libs/rich/protocol.py +++ b/libs/rich/protocol.py @@ -1,6 +1,10 @@ -from typing import Any +from typing import Any, Callable, cast, Set, TYPE_CHECKING +from inspect import isclass -from .abc import RichRenderable +if TYPE_CHECKING: + from rich.console import RenderableType + +_GIBBERISH = """aihwerij235234ljsdnp34ksodfipwoe234234jlskjdf""" def is_renderable(check_object: Any) -> bool: @@ -10,3 +14,29 @@ def is_renderable(check_object: Any) -> bool: or hasattr(check_object, "__rich__") or hasattr(check_object, "__rich_console__") ) + + +def rich_cast(renderable: object) -> "RenderableType": + """Cast an object to a renderable by calling __rich__ if present. + + Args: + renderable (object): A potentially renderable object + + Returns: + object: The result of recursively calling __rich__. + """ + from rich.console import RenderableType + + rich_visited_set: Set[type] = set() # Prevent potential infinite loop + while hasattr(renderable, "__rich__") and not isclass(renderable): + # Detect object which claim to have all the attributes + if hasattr(renderable, _GIBBERISH): + return repr(renderable) + cast_method = getattr(renderable, "__rich__") + renderable = cast_method() + renderable_type = type(renderable) + if renderable_type in rich_visited_set: + break + rich_visited_set.add(renderable_type) + + return cast(RenderableType, renderable) diff --git a/libs/rich/repr.py b/libs/rich/repr.py index 51566eb18..5fd2f4360 100644 --- a/libs/rich/repr.py +++ b/libs/rich/repr.py @@ -1,32 +1,151 @@ -from typing import Any, Iterable, List, Union, Tuple, Type, TypeVar +from functools import partial +import inspect + +from typing import ( + Any, + Callable, + Iterable, + List, + Optional, + overload, + Union, + Tuple, + Type, + TypeVar, +) T = TypeVar("T") -RichReprResult = Iterable[Union[Any, Tuple[Any], Tuple[str, Any], Tuple[str, Any, Any]]] +Result = Iterable[Union[Any, Tuple[Any], Tuple[str, Any], Tuple[str, Any, Any]]] +RichReprResult = Result + + +class ReprError(Exception): + """An error occurred when attempting to build a repr.""" + + +@overload +def auto(cls: Optional[T]) -> T: + ... + + +@overload +def auto(*, angular: bool = False) -> Callable[[T], T]: + ... -def rich_repr(cls: Type[T]) -> Type[T]: +def auto( + cls: Optional[T] = None, *, angular: Optional[bool] = None +) -> Union[T, Callable[[T], T]]: """Class decorator to create __repr__ from __rich_repr__""" - def auto_repr(self) -> str: - repr_str: List[str] = [] - append = repr_str.append - for arg in self.__rich_repr__(): - if isinstance(arg, tuple): - if len(arg) == 1: - append(repr(arg[0])) + def do_replace(cls: Type[T], angular: Optional[bool] = None) -> Type[T]: + def auto_repr(self: Type[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: + 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: - key, value, *default = arg - if len(default) and default[0] == value: - continue - append(f"{key}={value!r}") + append(repr(arg)) + if angular: + return f"<{self.__class__.__name__} {' '.join(repr_str)}>" else: - append(repr(arg)) - return f"{self.__class__.__name__}({', '.join(repr_str)})" + return f"{self.__class__.__name__}({', '.join(repr_str)})" + + def auto_rich_repr(self: Type[T]) -> Result: + """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 + + auto_repr.__doc__ = "Return repr(self)" + cls.__repr__ = auto_repr # type: ignore + if angular is not None: + cls.__rich_repr__.angular = angular # type: ignore + return cls + + if cls is None: + return partial(do_replace, angular=angular) # type: ignore + else: + return do_replace(cls, angular=angular) # type: ignore + + +@overload +def rich_repr(cls: Optional[T]) -> T: + ... + + +@overload +def rich_repr(*, angular: bool = False) -> Callable[[T], T]: + ... + + +def rich_repr( + cls: Optional[T] = None, *, angular: bool = False +) -> Union[T, Callable[[T], T]]: + if cls is None: + return auto(angular=angular) + else: + return auto(cls) + + +if __name__ == "__main__": + + @auto + class Foo: + def __rich_repr__(self) -> Result: + yield "foo" + yield "bar", {"shopping": ["eggs", "ham", "pineapple"]} + yield "buy", "hand sanitizer" + + foo = Foo() + from rich.console import Console + + console = Console() + + console.rule("Standard repr") + console.print(foo) + + console.print(foo, width=60) + console.print(foo, width=30) + + console.rule("Angular repr") + Foo.__rich_repr__.angular = True # type: ignore - auto_repr.__doc__ = "Return repr(self)" - cls.__repr__ = auto_repr # type: ignore + console.print(foo) - return cls + console.print(foo, width=60) + console.print(foo, width=30) diff --git a/libs/rich/scope.py b/libs/rich/scope.py index 9ba08b9f7..a1b76969c 100644 --- a/libs/rich/scope.py +++ b/libs/rich/scope.py @@ -1,5 +1,5 @@ from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, Tuple +from typing import TYPE_CHECKING, Any, Optional, Tuple from .highlighter import ReprHighlighter from .panel import Panel @@ -12,13 +12,13 @@ if TYPE_CHECKING: def render_scope( - scope: Mapping, + scope: "Mapping[str, Any]", *, - title: TextType = None, + title: Optional[TextType] = None, sort_keys: bool = True, indent_guides: bool = False, - max_length: int = None, - max_string: int = None, + max_length: Optional[int] = None, + max_string: Optional[int] = None, ) -> "ConsoleRenderable": """Render python variables in a given scope. diff --git a/libs/rich/screen.py b/libs/rich/screen.py index 5a3fb4c0f..b4f7fd19d 100644 --- a/libs/rich/screen.py +++ b/libs/rich/screen.py @@ -1,13 +1,18 @@ -from typing import TYPE_CHECKING +from typing import Optional, TYPE_CHECKING -from .measure import Measurement from .segment import Segment from .style import StyleType from ._loop import loop_last if TYPE_CHECKING: - from .console import Console, ConsoleOptions, RenderResult, RenderableType + from .console import ( + Console, + ConsoleOptions, + RenderResult, + RenderableType, + Group, + ) class Screen: @@ -18,11 +23,19 @@ class Screen: style (StyleType, optional): Optional background style. Defaults to None. """ + renderable: "RenderableType" + def __init__( - self, renderable: "RenderableType" = None, style: StyleType = None + self, + *renderables: "RenderableType", + style: Optional[StyleType] = None, + application_mode: bool = False, ) -> None: - self.renderable = renderable + from rich.console import Group + + self.renderable = Group(*renderables) self.style = style + self.application_mode = application_mode def __rich_console__( self, console: "Console", options: "ConsoleOptions" @@ -34,7 +47,7 @@ class Screen: self.renderable or "", render_options, style=style, pad=True ) lines = Segment.set_shape(lines, width, height, style=style) - new_line = Segment.line() + new_line = Segment("\n\r") if self.application_mode else Segment.line() for last, line in loop_last(lines): yield from line if not last: diff --git a/libs/rich/segment.py b/libs/rich/segment.py index d7953daf6..97679cefc 100644 --- a/libs/rich/segment.py +++ b/libs/rich/segment.py @@ -1,13 +1,34 @@ from enum import IntEnum - -from typing import Dict, NamedTuple, Optional - -from .cells import cell_len, set_cell_size -from .style import Style - +from functools import lru_cache from itertools import filterfalse +from logging import getLogger from operator import attrgetter -from typing import Iterable, List, Sequence, Union, Tuple +from typing import ( + TYPE_CHECKING, + Dict, + Iterable, + List, + NamedTuple, + Optional, + Sequence, + Tuple, + Type, + Union, +) + +from .cells import ( + _is_single_cell_widths, + cell_len, + get_character_cell_size, + set_cell_size, +) +from .repr import Result, rich_repr +from .style import Style + +if TYPE_CHECKING: + from .console import Console, ConsoleOptions, RenderResult + +log = getLogger("rich") class ControlType(IntEnum): @@ -25,7 +46,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 @@ -35,6 +56,7 @@ ControlCode = Union[ ] +@rich_repr() class Segment(NamedTuple): """A piece of text with associated style. Segments are produced by the Console render process and are ultimately converted in to strings to be written to the terminal. @@ -52,12 +74,14 @@ class Segment(NamedTuple): control: Optional[Sequence[ControlCode]] = None """Optional sequence of control codes.""" - def __repr__(self) -> str: - """Simplified repr.""" - if self.control: - return f"Segment({self.text!r}, {self.style!r}, {self.control!r})" + def __rich_repr__(self) -> Result: + yield self.text + if self.control is None: + if self.style is not None: + yield self.style else: - return f"Segment({self.text!r}, {self.style!r})" + yield self.style + yield self.control def __bool__(self) -> bool: """Check if the segment contains text.""" @@ -74,6 +98,66 @@ class Segment(NamedTuple): return self.control is not None @classmethod + @lru_cache(1024 * 16) + def _split_cells(cls, segment: "Segment", cut: int) -> Tuple["Segment", "Segment"]: # type: ignore + + text, style, control = segment + _Segment = Segment + + cell_length = segment.cell_length + if cut >= cell_length: + return segment, _Segment("", style, control) + + cell_size = get_character_cell_size + + pos = int((cut / cell_length) * len(text)) + + before = text[:pos] + cell_pos = cell_len(before) + if cell_pos == cut: + return ( + _Segment(before, style, control), + _Segment(text[pos:], style, control), + ) + while pos < len(text): + char = text[pos] + pos += 1 + cell_pos += cell_size(char) + before = text[:pos] + if cell_pos == cut: + return ( + _Segment(before, style, control), + _Segment(text[pos:], style, control), + ) + if cell_pos > cut: + return ( + _Segment(before[: pos - 1] + " ", style, control), + _Segment(" " + text[pos:], style, control), + ) + + def split_cells(self, cut: int) -> Tuple["Segment", "Segment"]: + """Split segment in to two segments at the specified column. + + If the cut point falls in the middle of a 2-cell wide character then it is replaced + by two spaces, to preserve the display width of the parent segment. + + Returns: + Tuple[Segment, Segment]: Two segments. + """ + text, style, control = self + + if _is_single_cell_widths(text): + # Fast path with all 1 cell characters + if cut >= len(text): + return self, Segment("", style, control) + return ( + Segment(text[:cut], style, control), + Segment(text[cut:], style, control), + ) + + return self._split_cells(self, cut) + + @classmethod def line(cls) -> "Segment": """Make a new line segment.""" return cls("\n") @@ -82,8 +166,8 @@ class Segment(NamedTuple): def apply_style( cls, segments: Iterable["Segment"], - style: Style = None, - post_style: Style = None, + style: Optional[Style] = None, + post_style: Optional[Style] = None, ) -> Iterable["Segment"]: """Apply style(s) to an iterable of segments. @@ -97,28 +181,31 @@ class Segment(NamedTuple): Returns: Iterable[Segments]: A new iterable of segments (possibly the same iterable). """ + result_segments = segments if style: apply = style.__add__ - segments = ( + result_segments = ( cls(text, None if control else apply(_style), control) - for text, _style, control in segments + for text, _style, control in result_segments ) if post_style: - segments = ( + result_segments = ( cls( text, - None - if control - else (_style + post_style if _style else post_style), + ( + None + if control + else (_style + post_style if _style else post_style) + ), control, ) - for text, _style, control in segments + for text, _style, control in result_segments ) - return segments + return result_segments @classmethod def filter_control( - cls, segments: Iterable["Segment"], is_control=False + cls, segments: Iterable["Segment"], is_control: bool = False ) -> Iterable["Segment"]: """Filter segments by ``is_control`` attribute. @@ -169,7 +256,7 @@ class Segment(NamedTuple): cls, segments: Iterable["Segment"], length: int, - style: Style = None, + style: Optional[Style] = None, pad: bool = True, include_new_lines: bool = True, ) -> Iterable[List["Segment"]]: @@ -213,7 +300,11 @@ class Segment(NamedTuple): @classmethod def adjust_line_length( - cls, line: List["Segment"], length: int, style: Style = None, pad: bool = True + cls, + line: List["Segment"], + length: int, + style: Optional[Style] = None, + pad: bool = True, ) -> List["Segment"]: """Adjust a line to a given width (cropping or padding as required). @@ -262,7 +353,8 @@ class Segment(NamedTuple): Returns: int: The length of the line. """ - return sum(segment.cell_length for segment in line) + _cell_len = cell_len + return sum(_cell_len(segment.text) for segment in line) @classmethod def get_shape(cls, lines: List[List["Segment"]]) -> Tuple[int, int]: @@ -283,8 +375,8 @@ class Segment(NamedTuple): cls, lines: List[List["Segment"]], width: int, - height: int = None, - style: Style = None, + height: Optional[int] = None, + style: Optional[Style] = None, new_lines: bool = False, ) -> List[List["Segment"]]: """Set the shape of a list of lines (enclosing rectangle). @@ -293,34 +385,117 @@ class Segment(NamedTuple): lines (List[List[Segment]]): A list of lines. width (int): Desired width. height (int, optional): Desired height or None for no change. - style (Style, optional): Style of any padding added. Defaults to None. + style (Style, optional): Style of any padding added. new_lines (bool, optional): Padded lines should include "\n". Defaults to False. Returns: - List[List[Segment]]: New list of lines that fits width x height. + List[List[Segment]]: New list of lines. """ - if height is None: - height = len(lines) - shaped_lines: List[List[Segment]] = [] - pad_line = ( - [Segment(" " * width, style), Segment("\n")] - if new_lines - else [Segment(" " * width, style)] + _height = height or len(lines) + + blank = ( + [cls(" " * width + "\n", style)] if new_lines else [cls(" " * width, style)] ) - append = shaped_lines.append adjust_line_length = cls.adjust_line_length - line: Optional[List[Segment]] - iter_lines = iter(lines) - for _ in range(height): - line = next(iter_lines, None) - if line is None: - append(pad_line) - else: - append(adjust_line_length(line, width, style=style)) + shaped_lines = lines[:_height] + shaped_lines[:] = [ + adjust_line_length(line, width, style=style) for line in lines + ] + if len(shaped_lines) < _height: + shaped_lines.extend([blank] * (_height - len(shaped_lines))) return shaped_lines @classmethod + def align_top( + cls: Type["Segment"], + lines: List[List["Segment"]], + width: int, + height: int, + style: Style, + new_lines: bool = False, + ) -> List[List["Segment"]]: + """Aligns lines to top (adds extra lines to bottom as required). + + Args: + lines (List[List[Segment]]): A list of lines. + width (int): Desired width. + height (int, optional): Desired height or None for no change. + style (Style): Style of any padding added. + new_lines (bool, optional): Padded lines should include "\n". Defaults to False. + + Returns: + List[List[Segment]]: New list of lines. + """ + extra_lines = height - len(lines) + if not extra_lines: + return lines[:] + lines = lines[:height] + blank = cls(" " * width + "\n", style) if new_lines else cls(" " * width, style) + lines = lines + [[blank]] * extra_lines + return lines + + @classmethod + def align_bottom( + cls: Type["Segment"], + lines: List[List["Segment"]], + width: int, + height: int, + style: Style, + new_lines: bool = False, + ) -> List[List["Segment"]]: + """Aligns render to bottom (adds extra lines above as required). + + Args: + lines (List[List[Segment]]): A list of lines. + width (int): Desired width. + height (int, optional): Desired height or None for no change. + style (Style): Style of any padding added. Defaults to None. + new_lines (bool, optional): Padded lines should include "\n". Defaults to False. + + Returns: + List[List[Segment]]: New list of lines. + """ + extra_lines = height - len(lines) + if not extra_lines: + return lines[:] + lines = lines[:height] + blank = cls(" " * width + "\n", style) if new_lines else cls(" " * width, style) + lines = [[blank]] * extra_lines + lines + return lines + + @classmethod + def align_middle( + cls: Type["Segment"], + lines: List[List["Segment"]], + width: int, + height: int, + style: Style, + new_lines: bool = False, + ) -> List[List["Segment"]]: + """Aligns lines to middle (adds extra lines to above and below as required). + + Args: + lines (List[List[Segment]]): A list of lines. + width (int): Desired width. + height (int, optional): Desired height or None for no change. + style (Style): Style of any padding added. + new_lines (bool, optional): Padded lines should include "\n". Defaults to False. + + Returns: + List[List[Segment]]: New list of lines. + """ + extra_lines = height - len(lines) + if not extra_lines: + return lines[:] + lines = lines[:height] + blank = cls(" " * width + "\n", style) if new_lines else cls(" " * width, style) + top_lines = extra_lines // 2 + bottom_lines = extra_lines - top_lines + lines = [[blank]] * top_lines + lines + [[blank]] * bottom_lines + return lines + + @classmethod def simplify(cls, segments: Iterable["Segment"]) -> Iterable["Segment"]: """Simplify an iterable of segments by combining contiguous segments with the same style. @@ -399,36 +574,147 @@ class Segment(NamedTuple): else: yield cls(text, None, control) + @classmethod + def divide( + cls, segments: Iterable["Segment"], cuts: Iterable[int] + ) -> Iterable[List["Segment"]]: + """Divides an iterable of segments in to portions. + + Args: + cuts (Iterable[int]): Cell positions where to divide. -if __name__ == "__main__": # pragma: no cover - from rich.syntax import Syntax - from rich.text import Text - from rich.console import Console + Yields: + [Iterable[List[Segment]]]: An iterable of Segments in List. + """ + split_segments: List["Segment"] = [] + add_segment = split_segments.append - code = """from rich.console import Console -console = Console() -text = Text.from_markup("Hello, [bold magenta]World[/]!") -console.print(text)""" + iter_cuts = iter(cuts) - text = Text.from_markup("Hello, [bold magenta]World[/]!") + while True: + try: + cut = next(iter_cuts) + except StopIteration: + return [] + if cut != 0: + break + yield [] + pos = 0 + for segment in segments: + while segment.text: + end_pos = pos + segment.cell_length + if end_pos < cut: + add_segment(segment) + pos = end_pos + break + + try: + if end_pos == cut: + add_segment(segment) + yield split_segments[:] + del split_segments[:] + pos = end_pos + break + else: + before, segment = segment.split_cells(cut - pos) + add_segment(before) + yield split_segments[:] + del split_segments[:] + pos = cut + finally: + try: + cut = next(iter_cuts) + except StopIteration: + if split_segments: + yield split_segments[:] + return + yield split_segments[:] + + +class Segments: + """A simple renderable to render an iterable of segments. This class may be useful if + you want to print segments outside of a __rich_console__ method. + + Args: + segments (Iterable[Segment]): An iterable of segments. + new_lines (bool, optional): Add new lines between segments. Defaults to False. + """ + + def __init__(self, segments: Iterable[Segment], new_lines: bool = False) -> None: + self.segments = list(segments) + self.new_lines = new_lines + + 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 + else: + yield from self.segments + + +class SegmentLines: + def __init__(self, lines: Iterable[List[Segment]], new_lines: bool = False) -> None: + """A simple renderable containing a number of lines of segments. May be used as an intermediate + in rendering process. + + Args: + lines (Iterable[List[Segment]]): Lists of segments forming lines. + new_lines (bool, optional): Insert new lines after each line. Defaults to False. + """ + self.lines = list(lines) + self.new_lines = new_lines + + def __rich_console__( + self, console: "Console", options: "ConsoleOptions" + ) -> "RenderResult": + if self.new_lines: + new_line = Segment.line() + for line in self.lines: + yield from line + yield new_line + else: + for line in self.lines: + yield from line + + +if __name__ == "__main__": + + if __name__ == "__main__": # pragma: no cover + from rich.console import Console + from rich.syntax import Syntax + from rich.text import Text + + code = """from rich.console import Console console = Console() + text = Text.from_markup("Hello, [bold magenta]World[/]!") + console.print(text)""" - console.rule("rich.Segment") - console.print( - "A Segment is the last step in the Rich render process before generating text with ANSI codes." - ) - console.print("\nConsider the following code:\n") - console.print(Syntax(code, "python", line_numbers=True)) - console.print() - console.print( - "When you call [b]print()[/b], Rich [i]renders[/i] the object in to the the following:\n" - ) - fragments = list(console.render(text)) - console.print(fragments) - console.print() - console.print("The Segments are then processed to produce the following output:\n") - console.print(text) - console.print( - "\nYou will only need to know this if you are implementing your own Rich renderables." - ) + text = Text.from_markup("Hello, [bold magenta]World[/]!") + + console = Console() + + console.rule("rich.Segment") + console.print( + "A Segment is the last step in the Rich render process before generating text with ANSI codes." + ) + console.print("\nConsider the following code:\n") + console.print(Syntax(code, "python", line_numbers=True)) + console.print() + console.print( + "When you call [b]print()[/b], Rich [i]renders[/i] the object in to the the following:\n" + ) + fragments = list(console.render(text)) + console.print(fragments) + console.print() + console.print( + "The Segments are then processed to produce the following output:\n" + ) + console.print(text) + console.print( + "\nYou will only need to know this if you are implementing your own Rich renderables." + ) diff --git a/libs/rich/spinner.py b/libs/rich/spinner.py index 373854f3c..5b13b1e9b 100644 --- a/libs/rich/spinner.py +++ b/libs/rich/spinner.py @@ -1,25 +1,30 @@ from typing import cast, List, Optional, TYPE_CHECKING from ._spinners import SPINNERS -from .console import Console from .measure import Measurement -from .style import StyleType -from .text import Text, TextType +from .table import Table +from .text import Text if TYPE_CHECKING: - from .console import Console, ConsoleOptions, RenderResult + from .console import Console, ConsoleOptions, RenderResult, RenderableType + from .style import StyleType class Spinner: def __init__( - self, name: str, text: TextType = "", *, style: StyleType = None, speed=1.0 + self, + name: str, + text: "RenderableType" = "", + *, + style: Optional["StyleType"] = None, + speed: float = 1.0, ) -> None: """A spinner animation. Args: name (str): Name of spinner (run python -m rich.spinner). - text (TextType, optional): Text to display at the right of the spinner. Defaults to "". - style (StyleType, optional): Style for sinner amimation. Defaults to None. + text (RenderableType, optional): A renderable to display at the right of the spinner (str or Text typically). Defaults to "". + style (StyleType, optional): Style for spinner animation. Defaults to None. speed (float, optional): Speed factor for animation. Defaults to 1.0. Raises: @@ -29,22 +34,19 @@ class Spinner: spinner = SPINNERS[name] except KeyError: raise KeyError(f"no spinner called {name!r}") - self.text = text + self.text = Text.from_markup(text) if isinstance(text, str) else text self.frames = cast(List[str], spinner["frames"])[:] self.interval = cast(float, spinner["interval"]) self.start_time: Optional[float] = None self.style = style self.speed = speed - self.time = 0.0 + self.frame_no_offset: float = 0.0 + self._update_speed = 0.0 def __rich_console__( self, console: "Console", options: "ConsoleOptions" ) -> "RenderResult": - time = console.get_time() - if self.start_time is None: - self.start_time = time - text = self.render(time - self.start_time) - yield text + yield self.render(console.get_time()) def __rich_measure__( self, console: "Console", options: "ConsoleOptions" @@ -52,18 +54,60 @@ class Spinner: text = self.render(0) return Measurement.get(console, options, text) - def render(self, time: float) -> Text: + def render(self, time: float) -> "RenderableType": """Render the spinner for a given time. Args: time (float): Time in seconds. Returns: - Text: A Text instance containing animation frame. + RenderableType: A renderable containing animation frame. + """ + if self.start_time is None: + self.start_time = time + + frame_no = ((time - self.start_time) * self.speed) / ( + self.interval / 1000.0 + ) + self.frame_no_offset + frame = Text( + self.frames[int(frame_no) % len(self.frames)], style=self.style or "" + ) + + if self._update_speed: + self.frame_no_offset = frame_no + self.start_time = time + self.speed = self._update_speed + self._update_speed = 0.0 + + if not self.text: + return frame + elif isinstance(self.text, (str, Text)): + return Text.assemble(frame, " ", self.text) + else: + table = Table.grid(padding=1) + table.add_row(frame, self.text) + return table + + def update( + self, + *, + text: "RenderableType" = "", + style: Optional["StyleType"] = None, + speed: Optional[float] = None, + ) -> None: + """Updates attributes of a spinner after it has been started. + + Args: + text (RenderableType, optional): A renderable to display at the right of the spinner (str or Text typically). Defaults to "". + style (StyleType, optional): Style for spinner animation. Defaults to None. + speed (float, optional): Speed factor for animation. Defaults to None. """ - frame_no = int((time * self.speed) / (self.interval / 1000.0)) - frame = Text(self.frames[frame_no % len(self.frames)], style=self.style or "") - return Text.assemble(frame, " ", self.text) if self.text else frame + if text: + self.text = Text.from_markup(text) if isinstance(text, str) else text + if style: + self.style = style + if speed: + self._update_speed = speed if __name__ == "__main__": # pragma: no cover diff --git a/libs/rich/status.py b/libs/rich/status.py index 62f1ba95d..09eff405e 100644 --- a/libs/rich/status.py +++ b/libs/rich/status.py @@ -1,11 +1,11 @@ -from typing import Optional +from types import TracebackType +from typing import Optional, Type from .console import Console, RenderableType from .jupyter import JupyterMixin from .live import Live from .spinner import Spinner from .style import StyleType -from .table import Table class Status(JupyterMixin): @@ -24,33 +24,26 @@ class Status(JupyterMixin): self, status: RenderableType, *, - console: Console = None, + console: Optional[Console] = None, spinner: str = "dots", spinner_style: StyleType = "status.spinner", speed: float = 1.0, refresh_per_second: float = 12.5, ): self.status = status - self.spinner = spinner self.spinner_style = spinner_style self.speed = speed - self._spinner = Spinner(spinner, style=spinner_style, speed=speed) + self._spinner = Spinner(spinner, text=status, style=spinner_style, speed=speed) self._live = Live( self.renderable, console=console, refresh_per_second=refresh_per_second, transient=True, ) - self.update( - status=status, spinner=spinner, spinner_style=spinner_style, speed=speed - ) @property - def renderable(self) -> Table: - """Get the renderable for the status (a table with spinner and status).""" - table = Table.grid(padding=1) - table.add_row(self._spinner, self.status) - return table + def renderable(self) -> Spinner: + return self._spinner @property def console(self) -> "Console": @@ -64,7 +57,7 @@ class Status(JupyterMixin): spinner: Optional[str] = None, spinner_style: Optional[StyleType] = None, speed: Optional[float] = None, - ): + ) -> None: """Update status. Args: @@ -75,16 +68,19 @@ class Status(JupyterMixin): """ if status is not None: self.status = status - if spinner is not None: - self.spinner = spinner if spinner_style is not None: self.spinner_style = spinner_style if speed is not None: self.speed = speed - self._spinner = Spinner( - self.spinner, style=self.spinner_style, speed=self.speed - ) - self._live.update(self.renderable, refresh=True) + if spinner is not None: + self._spinner = Spinner( + spinner, text=self.status, style=self.spinner_style, speed=self.speed + ) + self._live.update(self.renderable, refresh=True) + else: + self._spinner.update( + text=self.status, style=self.spinner_style, speed=self.speed + ) def start(self) -> None: """Start the status animation.""" @@ -101,7 +97,12 @@ class Status(JupyterMixin): self.start() return self - def __exit__(self, exc_type, exc_val, exc_tb) -> None: + def __exit__( + self, + exc_type: Optional[Type[BaseException]], + exc_val: Optional[BaseException], + exc_tb: Optional[TracebackType], + ) -> None: self.stop() diff --git a/libs/rich/style.py b/libs/rich/style.py index 594bcd52d..0787c3314 100644 --- a/libs/rich/style.py +++ b/libs/rich/style.py @@ -1,13 +1,15 @@ import sys from functools import lru_cache +from marshal import loads, 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, Result from .terminal_theme import DEFAULT_TERMINAL_THEME, TerminalTheme + # Style instances and style definitions are often interchangeable StyleType = Union[str, "Style"] @@ -26,6 +28,7 @@ class _Bit: return None +@rich_repr class Style: """A terminal style. @@ -59,6 +62,7 @@ class Style: _set_attributes: int _hash: int _null: bool + _meta: Optional[bytes] __slots__ = [ "_color", @@ -71,6 +75,7 @@ class Style: "_style_definition", "_hash", "_null", + "_meta", ] # maps bits on to SGR parameter @@ -90,25 +95,51 @@ class Style: 12: "53", } + STYLE_ATTRIBUTES = { + "dim": "dim", + "d": "dim", + "bold": "bold", + "b": "bold", + "italic": "italic", + "i": "italic", + "underline": "underline", + "u": "underline", + "blink": "blink", + "blink2": "blink2", + "reverse": "reverse", + "r": "reverse", + "conceal": "conceal", + "c": "conceal", + "strike": "strike", + "s": "strike", + "underline2": "underline2", + "uu": "underline2", + "frame": "frame", + "encircle": "encircle", + "overline": "overline", + "o": "overline", + } + def __init__( self, *, - color: Union[Color, str] = None, - bgcolor: Union[Color, str] = None, - bold: bool = None, - dim: bool = None, - italic: bool = None, - underline: bool = None, - blink: bool = None, - blink2: bool = None, - reverse: bool = None, - conceal: bool = None, - strike: bool = None, - underline2: bool = None, - frame: bool = None, - encircle: bool = None, - overline: bool = None, - link: str = None, + color: Optional[Union[Color, str]] = None, + bgcolor: Optional[Union[Color, str]] = None, + bold: Optional[bool] = None, + dim: Optional[bool] = None, + italic: Optional[bool] = None, + underline: Optional[bool] = None, + blink: Optional[bool] = None, + blink2: Optional[bool] = None, + reverse: Optional[bool] = None, + conceal: Optional[bool] = None, + strike: Optional[bool] = None, + underline2: Optional[bool] = None, + frame: Optional[bool] = None, + encircle: Optional[bool] = None, + overline: Optional[bool] = None, + link: Optional[str] = None, + meta: Optional[Dict[str, Any]] = None, ): self._ansi: Optional[str] = None self._style_definition: Optional[str] = None @@ -158,7 +189,8 @@ class Style: ) self._link = link - self._link_id = f"{time()}-{randint(0, 999999)}" if link else "" + self._link_id = f"{randint(0, 999999)}" if link else "" + self._meta = None if meta is None else dumps(meta) self._hash = hash( ( self._color, @@ -166,9 +198,10 @@ class Style: 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": @@ -176,14 +209,16 @@ class Style: return NULL_STYLE @classmethod - def from_color(cls, color: Color = None, bgcolor: Color = None) -> "Style": + def from_color( + cls, color: Optional[Color] = None, bgcolor: Optional[Color] = None + ) -> "Style": """Create a new style with colors and no attributes. Returns: color (Optional[Color]): A (foreground) color, or None for no color. Defaults to None. bgcolor (Optional[Color]): A (background) color, or None for no color. Defaults to None. """ - style = cls.__new__(Style) + style: Style = cls.__new__(Style) style._ansi = None style._style_definition = None style._color = color @@ -192,6 +227,7 @@ class Style: style._attributes = 0 style._link = None style._link_id = "" + style._meta = None style._hash = hash( ( color, @@ -199,11 +235,60 @@ class Style: None, None, None, + None, ) ) style._null = not (color or bgcolor) return style + @classmethod + def from_meta(cls, meta: Optional[Dict[str, Any]]) -> "Style": + """Create a new style with meta data. + + Returns: + meta (Optional[Dict[str, Any]]): A dictionary of meta data. Defaults to None. + """ + style: Style = cls.__new__(Style) + style._ansi = None + style._style_definition = None + style._color = None + style._bgcolor = None + style._set_attributes = 0 + style._attributes = 0 + style._link = None + style._link_id = "" + style._meta = dumps(meta) + style._hash = hash( + ( + None, + None, + None, + None, + None, + style._meta, + ) + ) + style._null = not (meta) + return style + + @classmethod + def on(cls, meta: Optional[Dict[str, Any]] = None, **handlers: Any) -> "Style": + """Create a blank style with meta information. + + Example: + style = Style.on(click=self.on_click) + + Args: + meta (Optiona[Dict[str, Any]], optional): An optional dict of meta information. + **handlers (Any): Keyword arguments are translated in to handlers. + + Returns: + Style: A Style with meta information attached. + """ + meta = {} if meta is None else meta + meta.update({f"@{key}": value for key, value in handlers.items()}) + return cls.from_meta(meta) + bold = _Bit(0) dim = _Bit(1) italic = _Bit(2) @@ -340,9 +425,24 @@ class Style: 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) -> Result: + 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): @@ -353,6 +453,7 @@ class Style: 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: @@ -384,11 +485,16 @@ class Style: return Style(bgcolor=self.bgcolor) @property + def meta(self) -> Dict[str, Any]: + """Get meta information (can not be changed after construction).""" + return {} if self._meta is None else cast(Dict[str, Any], loads(self._meta)) + + @property def without_color(self) -> "Style": """Get a copy of the style with color removed.""" if self._null: return NULL_STYLE - style = self.__new__(Style) + style: Style = self.__new__(Style) style._ansi = None style._style_definition = None style._color = None @@ -396,9 +502,10 @@ class Style: style._attributes = self._attributes style._set_attributes = self._set_attributes style._link = self._link - style._link_id = f"{time()}-{randint(0, 999999)}" if self._link else "" + style._link_id = f"{randint(0, 999999)}" if self._link else "" style._hash = self._hash style._null = False + style._meta = None return style @classmethod @@ -418,33 +525,10 @@ class Style: if style_definition.strip() == "none" or not style_definition: return cls.null() - style_attributes = { - "dim": "dim", - "d": "dim", - "bold": "bold", - "b": "bold", - "italic": "italic", - "i": "italic", - "underline": "underline", - "u": "underline", - "blink": "blink", - "blink2": "blink2", - "reverse": "reverse", - "r": "reverse", - "conceal": "conceal", - "c": "conceal", - "strike": "strike", - "s": "strike", - "underline2": "underline2", - "uu": "underline2", - "frame": "frame", - "encircle": "encircle", - "overline": "overline", - "o": "overline", - } + STYLE_ATTRIBUTES = cls.STYLE_ATTRIBUTES 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()) @@ -464,7 +548,7 @@ class Style: elif word == "not": word = next(words, "") - attribute = style_attributes.get(word) + attribute = STYLE_ATTRIBUTES.get(word) if attribute is None: raise errors.StyleSyntaxError( f"expected style attribute after 'not', found {word!r}" @@ -477,8 +561,8 @@ class Style: raise errors.StyleSyntaxError("URL expected after 'link'") link = word - elif word in style_attributes: - attributes[style_attributes[word]] = True + elif word in STYLE_ATTRIBUTES: + attributes[STYLE_ATTRIBUTES[word]] = True else: try: @@ -492,7 +576,7 @@ class Style: return style @lru_cache(maxsize=1024) - def get_html_style(self, theme: TerminalTheme = None) -> str: + def get_html_style(self, theme: Optional[TerminalTheme] = None) -> str: """Get a CSS style rule.""" theme = theme or DEFAULT_TERMINAL_THEME css: List[str] = [] @@ -562,7 +646,7 @@ class Style: """ if self._null: return NULL_STYLE - style = self.__new__(Style) + style: Style = self.__new__(Style) style._ansi = self._ansi style._style_definition = self._style_definition style._color = self._color @@ -570,12 +654,13 @@ class Style: style._attributes = self._attributes style._set_attributes = self._set_attributes style._link = self._link - style._link_id = f"{time()}-{randint(0, 999999)}" if self._link else "" + style._link_id = f"{randint(0, 999999)}" if self._link else "" style._hash = self._hash style._null = False + style._meta = self._meta return style - def update_link(self, link: str = None) -> "Style": + def update_link(self, link: Optional[str] = None) -> "Style": """Get a copy with a different value for link. Args: @@ -584,7 +669,7 @@ class Style: Returns: Style: A new Style instance. """ - style = self.__new__(Style) + style: Style = self.__new__(Style) style._ansi = self._ansi style._style_definition = self._style_definition style._color = self._color @@ -592,9 +677,10 @@ class Style: style._attributes = self._attributes style._set_attributes = self._set_attributes style._link = link - style._link_id = f"{time()}-{randint(0, 999999)}" if link else "" + style._link_id = f"{randint(0, 999999)}" if link else "" style._hash = self._hash style._null = False + style._meta = self._meta return style def render( @@ -637,12 +723,12 @@ class Style: def __add__(self, style: Optional["Style"]) -> "Style": if not (isinstance(style, Style) or style is None): - return NotImplemented # type: ignore + return NotImplemented if style is None or style._null: return self if self._null: return style - new_style = self.__new__(Style) + new_style: Style = self.__new__(Style) new_style._ansi = None new_style._style_definition = None new_style._color = style._color or self._color @@ -655,6 +741,10 @@ class 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 = dumps({**self.meta, **style.meta}) + else: + new_style._meta = self._meta or style._meta return new_style diff --git a/libs/rich/syntax.py b/libs/rich/syntax.py index 8d3851fca..759351907 100644 --- a/libs/rich/syntax.py +++ b/libs/rich/syntax.py @@ -1,9 +1,11 @@ import os.path import platform +from rich.containers import Lines import textwrap from abc import ABC, abstractmethod -from typing import Any, Dict, Iterable, Optional, Set, Tuple, Type, Union +from typing import Any, Dict, Iterable, List, Optional, Set, Tuple, Type, Union +from pygments.lexer import Lexer from pygments.lexers import get_lexer_by_name, guess_lexer_for_filename from pygments.style import Style as PygmentsStyle from pygments.styles import get_style_by_name @@ -23,9 +25,10 @@ from pygments.util import ClassNotFound from ._loop import loop_first from .color import Color, blend_rgb -from .console import Console, ConsoleOptions, JustifyMethod, RenderResult, Segment +from .console import Console, ConsoleOptions, JustifyMethod, RenderResult from .jupyter import JupyterMixin from .measure import Measurement +from .segment import Segment from .style import Style from .text import Text @@ -113,7 +116,7 @@ class SyntaxTheme(ABC): class PygmentsSyntaxTheme(SyntaxTheme): - """Syntax theme that delagates to Pygments theme.""" + """Syntax theme that delegates to Pygments theme.""" def __init__(self, theme: Union[str, Type[PygmentsStyle]]) -> None: self._style_cache: Dict[TokenType, Style] = {} @@ -192,7 +195,7 @@ class Syntax(JupyterMixin): Args: code (str): Code to highlight. - lexer_name (str): Lexer to use (see https://pygments.org/docs/lexers/) + lexer (Lexer | str): Lexer to use (see https://pygments.org/docs/lexers/) theme (str, optional): Color theme, aka Pygments style (see https://pygments.org/docs/styles/#getting-a-list-of-available-styles). Defaults to "monokai". dedent (bool, optional): Enable stripping of initial whitespace. Defaults to False. line_numbers (bool, optional): Enable rendering of line numbers. Defaults to False. @@ -224,22 +227,22 @@ class Syntax(JupyterMixin): def __init__( self, code: str, - lexer_name: str, + lexer: Union[Lexer, str], *, theme: Union[str, SyntaxTheme] = DEFAULT_THEME, dedent: bool = False, line_numbers: bool = False, start_line: int = 1, - line_range: Tuple[int, int] = None, - highlight_lines: Set[int] = None, + line_range: Optional[Tuple[int, int]] = None, + highlight_lines: Optional[Set[int]] = None, code_width: Optional[int] = None, tab_size: int = 4, word_wrap: bool = False, - background_color: str = None, + background_color: Optional[str] = None, indent_guides: bool = False, ) -> None: self.code = code - self.lexer_name = lexer_name + self._lexer = lexer self.dedent = dedent self.line_numbers = line_numbers self.start_line = start_line @@ -264,13 +267,13 @@ class Syntax(JupyterMixin): theme: Union[str, SyntaxTheme] = DEFAULT_THEME, dedent: bool = False, line_numbers: bool = False, - line_range: Tuple[int, int] = None, + line_range: Optional[Tuple[int, int]] = None, start_line: int = 1, - highlight_lines: Set[int] = None, + highlight_lines: Optional[Set[int]] = None, code_width: Optional[int] = None, tab_size: int = 4, word_wrap: bool = False, - background_color: str = None, + background_color: Optional[str] = None, indent_guides: bool = False, ) -> "Syntax": """Construct a Syntax object from a file. @@ -331,11 +334,6 @@ class Syntax(JupyterMixin): def _get_base_style(self) -> Style: """Get the base style.""" - # default_style = ( - # Style(bgcolor=self.background_color) - # if self.background_color is not None - # else self._theme.get_background_style() - # ) default_style = self._theme.get_background_style() + self.background_style return default_style @@ -351,7 +349,28 @@ class Syntax(JupyterMixin): style = self._theme.get_style_for_token(token_type) return style.color - def highlight(self, code: str, line_range: Tuple[int, int] = None) -> Text: + @property + def lexer(self) -> Optional[Lexer]: + """The lexer for this syntax, or None if no lexer was found. + + Tries to find the lexer by name if a string was passed to the constructor. + """ + + if isinstance(self._lexer, Lexer): + return self._lexer + try: + return get_lexer_by_name( + self._lexer, + stripnl=False, + ensurenl=True, + tabsize=self.tab_size, + ) + except ClassNotFound: + return None + + def highlight( + self, code: str, line_range: Optional[Tuple[int, int]] = None + ) -> Text: """Highlight code and return a Text instance. Args: @@ -374,9 +393,10 @@ class Syntax(JupyterMixin): no_wrap=not self.word_wrap, ) _get_theme_style = self._theme.get_style_for_token - try: - lexer = get_lexer_by_name(self.lexer_name) - except ClassNotFound: + + lexer = self.lexer + + if lexer is None: text.append(code) else: if line_range: @@ -386,6 +406,8 @@ class Syntax(JupyterMixin): def line_tokenize() -> Iterable[Tuple[Any, str]]: """Split tokens to one per line.""" + assert lexer + for token_type, token in lexer.get_tokens(code): while token: line_token, new_line, token = token.partition("\n") @@ -496,10 +518,11 @@ class Syntax(JupyterMixin): start_line, end_line = self.line_range line_offset = max(0, start_line - 1) - code = textwrap.dedent(self.code) if self.dedent else self.code + ends_on_nl = self.code.endswith("\n") + code = self.code if ends_on_nl else self.code + "\n" + code = textwrap.dedent(code) if self.dedent else code code = code.expandtabs(self.tab_size) text = self.highlight(code, self.line_range) - text.remove_suffix("\n") ( background_style, @@ -508,6 +531,8 @@ class Syntax(JupyterMixin): ) = self._get_number_styles(console) if not self.line_numbers and not self.word_wrap and not self.line_range: + if not ends_on_nl: + text.remove_suffix("\n") # Simple case of just rendering text style = ( self._get_base_style() @@ -534,7 +559,7 @@ class Syntax(JupyterMixin): yield from syntax_line return - lines = text.split("\n") + lines: Union[List[Text], Lines] = text.split("\n", allow_blank=ends_on_nl) if self.line_range: lines = lines[line_offset:end_line] @@ -549,7 +574,7 @@ class Syntax(JupyterMixin): Text("\n") .join(lines) .with_indent_guides(self.tab_size, style=style) - .split("\n") + .split("\n", allow_blank=True) ) numbers_column_width = self._numbers_column_width @@ -672,7 +697,7 @@ if __name__ == "__main__": # pragma: no cover "--background-color", dest="background_color", default=None, - help="Overide background color", + help="Override background color", ) parser.add_argument( "-x", @@ -691,7 +716,7 @@ if __name__ == "__main__": # pragma: no cover code = sys.stdin.read() syntax = Syntax( code=code, - lexer_name=args.lexer_name, + lexer=args.lexer_name, line_numbers=args.line_numbers, word_wrap=args.word_wrap, theme=args.theme, diff --git a/libs/rich/table.py b/libs/rich/table.py index 8fc6379d9..0271d5fc6 100644 --- a/libs/rich/table.py +++ b/libs/rich/table.py @@ -1,11 +1,12 @@ from dataclasses import dataclass, field, replace from typing import ( - Dict, TYPE_CHECKING, + Dict, Iterable, List, NamedTuple, Optional, + Sequence, Tuple, Union, ) @@ -14,6 +15,7 @@ from . import box, errors from ._loop import loop_first_last, loop_last from ._pick import pick_bool from ._ratio import ratio_distribute, ratio_reduce +from .align import VerticalAlignMethod from .jupyter import JupyterMixin from .measure import Measurement from .padding import Padding, PaddingDimensions @@ -55,6 +57,9 @@ class Column: justify: "JustifyMethod" = "left" """str: How to justify text within the column ("left", "center", "right", or "full")""" + vertical: "VerticalAlignMethod" = "top" + """str: How to vertically align content ("top", "middle", or "bottom")""" + overflow: "OverflowMethod" = "ellipsis" """str: Overflow method.""" @@ -111,6 +116,8 @@ class _Cell(NamedTuple): """Style to apply to cell.""" renderable: "RenderableType" """Cell renderable.""" + vertical: VerticalAlignMethod + """Cell vertical alignment.""" class Table(JupyterMixin): @@ -122,7 +129,7 @@ class Table(JupyterMixin): caption (Union[str, Text], optional): The table caption rendered below. Defaults to None. width (int, optional): The width in characters of the table, or ``None`` to automatically fit. Defaults to None. min_width (Optional[int], optional): The minimum width of the table, or ``None`` for no minimum. Defaults to None. - box (box.Box, optional): One of the constants in box.py used to draw the edges (see :ref:`appendix_box`). Defaults to box.HEAVY_HEAD. + box (box.Box, optional): One of the constants in box.py used to draw the edges (see :ref:`appendix_box`), or ``None`` for no box lines. Defaults to box.HEAVY_HEAD. safe_box (Optional[bool], optional): Disable box characters that don't display on windows legacy terminal with *raster* fonts. Defaults to True. padding (PaddingDimensions, optional): Padding for cells (top, right, bottom, left). Defaults to (0, 1). collapse_padding (bool, optional): Enable collapsing of padding around cells. Defaults to False. @@ -134,7 +141,7 @@ class Table(JupyterMixin): show_lines (bool, optional): Draw lines between every row. Defaults to False. leading (bool, optional): Number of blank lines between rows (precludes ``show_lines``). Defaults to 0. style (Union[str, Style], optional): Default style for the table. Defaults to "none". - row_styles (List[Union, str], optional): Optional list of row styles, if more that one style is give then the styles will alternate. Defaults to None. + row_styles (List[Union, str], optional): Optional list of row styles, if more than one style is given then the styles will alternate. Defaults to None. header_style (Union[str, Style], optional): Style of the header. Defaults to "table.header". footer_style (Union[str, Style], optional): Style of the footer. Defaults to "table.footer". border_style (Union[str, Style], optional): Style of the border. Defaults to None. @@ -151,10 +158,10 @@ class Table(JupyterMixin): def __init__( self, *headers: Union[Column, str], - title: TextType = None, - caption: TextType = None, - width: int = None, - min_width: int = None, + title: Optional[TextType] = None, + caption: Optional[TextType] = None, + width: Optional[int] = None, + min_width: Optional[int] = None, box: Optional[box.Box] = box.HEAVY_HEAD, safe_box: Optional[bool] = None, padding: PaddingDimensions = (0, 1), @@ -167,12 +174,12 @@ class Table(JupyterMixin): show_lines: bool = False, leading: int = 0, style: StyleType = "none", - row_styles: Iterable[StyleType] = None, + row_styles: Optional[Iterable[StyleType]] = None, header_style: Optional[StyleType] = "table.header", footer_style: Optional[StyleType] = "table.footer", - border_style: StyleType = None, - title_style: StyleType = None, - caption_style: StyleType = None, + border_style: Optional[StyleType] = None, + title_style: Optional[StyleType] = None, + caption_style: Optional[StyleType] = None, title_justify: "JustifyMethod" = "center", caption_justify: "JustifyMethod" = "center", highlight: bool = False, @@ -201,10 +208,10 @@ class Table(JupyterMixin): self.border_style = border_style self.title_style = title_style self.caption_style = caption_style - self.title_justify = title_justify - self.caption_justify = caption_justify + self.title_justify: "JustifyMethod" = title_justify + self.caption_justify: "JustifyMethod" = caption_justify self.highlight = highlight - self.row_styles = list(row_styles or []) + self.row_styles: Sequence[StyleType] = list(row_styles or []) append_column = self.columns.append for header in headers: if isinstance(header, str): @@ -247,7 +254,7 @@ class Table(JupyterMixin): ) @property - def expand(self) -> int: + def expand(self) -> bool: """Setting a non-None self.width implies expand.""" return self._expand or self.width is not None @@ -330,15 +337,16 @@ class Table(JupyterMixin): header: "RenderableType" = "", footer: "RenderableType" = "", *, - header_style: StyleType = None, - footer_style: StyleType = None, - style: StyleType = None, + header_style: Optional[StyleType] = None, + footer_style: Optional[StyleType] = None, + style: Optional[StyleType] = None, justify: "JustifyMethod" = "left", + vertical: "VerticalAlignMethod" = "top", overflow: "OverflowMethod" = "ellipsis", - width: int = None, - min_width: int = None, - max_width: int = None, - ratio: int = None, + width: Optional[int] = None, + min_width: Optional[int] = None, + max_width: Optional[int] = None, + ratio: Optional[int] = None, no_wrap: bool = False, ) -> None: """Add a column to the table. @@ -352,6 +360,8 @@ class Table(JupyterMixin): footer_style (Union[str, Style], optional): Style for the footer, or None for default. Defaults to None. style (Union[str, Style], optional): Style for the column cells, or None for default. Defaults to None. justify (JustifyMethod, optional): Alignment for cells. Defaults to "left". + vertical (VerticalAlignMethod, optional): Vertical alignment, one of "top", "middle", or "bottom". Defaults to "top". + overflow (OverflowMethod): Overflow method: "crop", "fold", "ellipsis". Defaults to "ellipsis". width (int, optional): Desired width of column in characters, or None to fit to contents. Defaults to None. min_width (Optional[int], optional): Minimum width of column, or ``None`` for no minimum. Defaults to None. max_width (Optional[int], optional): Maximum width of column, or ``None`` for no maximum. Defaults to None. @@ -367,6 +377,7 @@ class Table(JupyterMixin): footer_style=footer_style or "", style=style or "", justify=justify, + vertical=vertical, overflow=overflow, width=width, min_width=min_width, @@ -379,7 +390,7 @@ class Table(JupyterMixin): def add_row( self, *renderables: Optional["RenderableType"], - style: StyleType = None, + style: Optional[StyleType] = None, end_section: bool = False, ) -> None: """Add a row of renderables. @@ -634,10 +645,18 @@ class Table(JupyterMixin): if any_padding: _Padding = Padding for first, last, (style, renderable) in loop_first_last(raw_cells): - yield _Cell(style, _Padding(renderable, get_padding(first, last))) + yield _Cell( + style, + _Padding(renderable, get_padding(first, last)), + getattr(renderable, "vertical", None) or column.vertical, + ) else: for (style, renderable) in raw_cells: - yield _Cell(style, renderable) + yield _Cell( + style, + renderable, + getattr(renderable, "vertical", None) or column.vertical, + ) def _get_padding_width(self, column_index: int) -> int: """Get extra width from padding.""" @@ -768,18 +787,45 @@ class Table(JupyterMixin): overflow=column.overflow, height=None, ) - cell_style = table_style + row_style + get_style(cell.style) lines = console.render_lines( - cell.renderable, render_options, style=cell_style + cell.renderable, + render_options, + style=get_style(cell.style) + row_style, ) max_height = max(max_height, len(lines)) cells.append(lines) + row_height = max(len(cell) for cell in cells) + + def align_cell( + cell: List[List[Segment]], + vertical: "VerticalAlignMethod", + width: int, + style: Style, + ) -> List[List[Segment]]: + if header_row: + vertical = "bottom" + elif footer_row: + vertical = "top" + + if vertical == "top": + return _Segment.align_top(cell, width, row_height, style) + elif vertical == "middle": + return _Segment.align_middle(cell, width, row_height, style) + return _Segment.align_bottom(cell, width, row_height, style) + cells[:] = [ _Segment.set_shape( - _cell, width, max_height, style=table_style + row_style + align_cell( + cell, + _cell.vertical, + width, + get_style(_cell.style) + row_style, + ), + width, + max_height, ) - for width, _cell in zip(widths, cells) + for width, _cell, cell, column in zip(widths, row_cell, cells, columns) ] if _box: @@ -844,74 +890,79 @@ class Table(JupyterMixin): if __name__ == "__main__": # pragma: no cover from rich.console import Console from rich.highlighter import ReprHighlighter - from rich.table import Table + from rich.table import Table as Table - table = Table( - title="Star Wars Movies", - caption="Rich example table", - caption_justify="right", - ) + from ._timer import timer + + with timer("Table render"): + table = Table( + title="Star Wars Movies", + caption="Rich example table", + caption_justify="right", + ) - table.add_column("Released", header_style="bright_cyan", style="cyan", no_wrap=True) - table.add_column("Title", style="magenta") - table.add_column("Box Office", justify="right", style="green") + table.add_column( + "Released", header_style="bright_cyan", style="cyan", no_wrap=True + ) + table.add_column("Title", style="magenta") + table.add_column("Box Office", justify="right", style="green") - table.add_row( - "Dec 20, 2019", - "Star Wars: The Rise of Skywalker", - "$952,110,690", - ) - table.add_row("May 25, 2018", "Solo: A Star Wars Story", "$393,151,347") - table.add_row( - "Dec 15, 2017", - "Star Wars Ep. V111: The Last Jedi", - "$1,332,539,889", - style="on black", - end_section=True, - ) - table.add_row( - "Dec 16, 2016", - "Rogue One: A Star Wars Story", - "$1,332,439,889", - ) + table.add_row( + "Dec 20, 2019", + "Star Wars: The Rise of Skywalker", + "$952,110,690", + ) + table.add_row("May 25, 2018", "Solo: A Star Wars Story", "$393,151,347") + table.add_row( + "Dec 15, 2017", + "Star Wars Ep. V111: The Last Jedi", + "$1,332,539,889", + style="on black", + end_section=True, + ) + table.add_row( + "Dec 16, 2016", + "Rogue One: A Star Wars Story", + "$1,332,439,889", + ) - def header(text: str) -> None: - console.print() - console.rule(highlight(text)) - console.print() - - console = Console() - highlight = ReprHighlighter() - header("Example Table") - console.print(table, justify="center") - - table.expand = True - header("expand=True") - console.print(table) - - table.width = 50 - header("width=50") - - console.print(table, justify="center") - - table.width = None - table.expand = False - table.row_styles = ["dim", "none"] - header("row_styles=['dim', 'none']") - - console.print(table, justify="center") - - table.width = None - table.expand = False - table.row_styles = ["dim", "none"] - table.leading = 1 - header("leading=1, row_styles=['dim', 'none']") - console.print(table, justify="center") - - table.width = None - table.expand = False - table.row_styles = ["dim", "none"] - table.show_lines = True - table.leading = 0 - header("show_lines=True, row_styles=['dim', 'none']") - console.print(table, justify="center") + def header(text: str) -> None: + console.print() + console.rule(highlight(text)) + console.print() + + console = Console() + highlight = ReprHighlighter() + header("Example Table") + console.print(table, justify="center") + + table.expand = True + header("expand=True") + console.print(table) + + table.width = 50 + header("width=50") + + console.print(table, justify="center") + + table.width = None + table.expand = False + table.row_styles = ["dim", "none"] + header("row_styles=['dim', 'none']") + + console.print(table, justify="center") + + table.width = None + table.expand = False + table.row_styles = ["dim", "none"] + table.leading = 1 + header("leading=1, row_styles=['dim', 'none']") + console.print(table, justify="center") + + table.width = None + table.expand = False + table.row_styles = ["dim", "none"] + table.show_lines = True + table.leading = 0 + header("show_lines=True, row_styles=['dim', 'none']") + console.print(table, justify="center") diff --git a/libs/rich/tabulate.py b/libs/rich/tabulate.py index 680bd3214..ca4fe293a 100644 --- a/libs/rich/tabulate.py +++ b/libs/rich/tabulate.py @@ -1,5 +1,6 @@ from collections.abc import Mapping -from typing import Optional +from typing import Any, Optional +import warnings from rich.console import JustifyMethod @@ -10,9 +11,9 @@ from .table import Table def tabulate_mapping( - mapping: Mapping, - title: str = None, - caption: str = None, + mapping: "Mapping[Any, Any]", + title: Optional[str] = None, + caption: Optional[str] = None, title_justify: Optional[JustifyMethod] = None, caption_justify: Optional[JustifyMethod] = None, ) -> Table: @@ -28,6 +29,7 @@ def tabulate_mapping( Returns: Table: A table instance which may be rendered by the Console. """ + warnings.warn("tabulate_mapping will be deprecated in Rich v11", DeprecationWarning) table = Table( show_header=False, title=title, diff --git a/libs/rich/terminal_theme.py b/libs/rich/terminal_theme.py index a5ca1c0c7..801ac0b7b 100644 --- a/libs/rich/terminal_theme.py +++ b/libs/rich/terminal_theme.py @@ -1,4 +1,4 @@ -from typing import List, Tuple +from typing import List, Optional, Tuple from .color_triplet import ColorTriplet from .palette import Palette @@ -22,7 +22,7 @@ class TerminalTheme: background: _ColorTuple, foreground: _ColorTuple, normal: List[_ColorTuple], - bright: List[_ColorTuple] = None, + bright: Optional[List[_ColorTuple]] = None, ) -> None: self.background_color = ColorTriplet(*background) self.foreground_color = ColorTriplet(*foreground) diff --git a/libs/rich/text.py b/libs/rich/text.py index c5edc0299..d9ac2c0d6 100644 --- a/libs/rich/text.py +++ b/libs/rich/text.py @@ -1,7 +1,8 @@ import re from functools import partial, reduce from math import gcd -from operator import attrgetter, itemgetter +from operator import itemgetter +from rich.emoji import EmojiVariant from typing import ( TYPE_CHECKING, Any, @@ -13,7 +14,6 @@ from typing import ( Optional, Tuple, Union, - cast, ) from ._loop import loop_last @@ -23,6 +23,7 @@ from .align import AlignMethod from .cells import cell_len, set_cell_size from .containers import Lines from .control import strip_control_codes +from .emoji import EmojiVariant from .jupyter import JupyterMixin from .measure import Measurement from .segment import Segment @@ -53,7 +54,11 @@ class Span(NamedTuple): """Style associated with the span.""" def __repr__(self) -> str: - return f"Span({self.start}, {self.end}, {str(self.style)!r})" + return ( + f"Span({self.start}, {self.end}, {self.style!r})" + if (isinstance(self.style, Style) and self.style._meta) + else f"Span({self.start}, {self.end}, {repr(self.style)})" + ) def __bool__(self) -> bool: return self.end > self.start @@ -129,17 +134,17 @@ class Text(JupyterMixin): text: str = "", style: Union[str, Style] = "", *, - justify: "JustifyMethod" = None, - overflow: "OverflowMethod" = None, - no_wrap: bool = None, + justify: Optional["JustifyMethod"] = None, + overflow: Optional["OverflowMethod"] = None, + no_wrap: Optional[bool] = None, end: str = "\n", tab_size: Optional[int] = 8, - spans: List[Span] = None, + spans: Optional[List[Span]] = None, ) -> None: self._text = [strip_control_codes(text)] self.style = style - self.justify = justify - self.overflow = overflow + self.justify: Optional["JustifyMethod"] = justify + self.overflow: Optional["OverflowMethod"] = overflow self.no_wrap = no_wrap self.end = end self.tab_size = tab_size @@ -178,7 +183,7 @@ class Text(JupyterMixin): return False def __getitem__(self, slice: Union[int, slice]) -> "Text": - def get_text_at(offset) -> "Text": + def get_text_at(offset: int) -> "Text": _Span = Span text = Text( self.plain[offset], @@ -208,6 +213,36 @@ class Text(JupyterMixin): """Get the number of cells required to render this text.""" return cell_len(self.plain) + @property + def markup(self) -> str: + """Get console markup to render this Text. + + Returns: + str: A string potentially creating markup tags. + """ + from .markup import escape + + output: List[str] = [] + + plain = self.plain + markup_spans = [ + (0, False, self.style), + *((span.start, False, span.style) for span in self._spans), + *((span.end, True, span.style) for span in self._spans), + (len(plain), True, self.style), + ] + markup_spans.sort(key=itemgetter(0, 1)) + position = 0 + append = output.append + for offset, closing, style in markup_spans: + if offset > position: + append(escape(plain[position:offset])) + position = offset + if style: + append(f"[/{style}]" if closing else f"[{style}]") + markup = "".join(output) + return markup + @classmethod def from_markup( cls, @@ -215,8 +250,9 @@ class Text(JupyterMixin): *, style: Union[str, Style] = "", emoji: bool = True, - justify: "JustifyMethod" = None, - overflow: "OverflowMethod" = None, + emoji_variant: Optional[EmojiVariant] = None, + justify: Optional["JustifyMethod"] = None, + overflow: Optional["OverflowMethod"] = None, ) -> "Text": """Create Text instance from markup. @@ -231,19 +267,57 @@ class Text(JupyterMixin): """ from .markup import render - rendered_text = render(text, style, emoji=emoji) + rendered_text = render(text, style, emoji=emoji, emoji_variant=emoji_variant) rendered_text.justify = justify rendered_text.overflow = overflow return rendered_text @classmethod + def from_ansi( + cls, + text: str, + *, + style: Union[str, Style] = "", + justify: Optional["JustifyMethod"] = None, + overflow: Optional["OverflowMethod"] = None, + no_wrap: Optional[bool] = None, + end: str = "\n", + tab_size: Optional[int] = 8, + ) -> "Text": + """Create a Text object from a string containing ANSI escape codes. + + Args: + text (str): A string containing escape codes. + style (Union[str, Style], optional): Base style for text. Defaults to "". + justify (str, optional): Justify method: "left", "center", "full", "right". Defaults to None. + overflow (str, optional): Overflow method: "crop", "fold", "ellipsis". Defaults to None. + no_wrap (bool, optional): Disable text wrapping, or None for default. Defaults to None. + end (str, optional): Character to end text with. Defaults to "\\\\n". + tab_size (int): Number of spaces per tab, or ``None`` to use ``console.tab_size``. Defaults to 8. + """ + from .ansi import AnsiDecoder + + joiner = Text( + "\n", + justify=justify, + overflow=overflow, + no_wrap=no_wrap, + end=end, + tab_size=tab_size, + style=style, + ) + decoder = AnsiDecoder() + result = joiner.join(line for line in decoder.decode(text)) + return result + + @classmethod def styled( cls, text: str, style: StyleType = "", *, - justify: "JustifyMethod" = None, - overflow: "OverflowMethod" = None, + justify: Optional["JustifyMethod"] = None, + overflow: Optional["OverflowMethod"] = None, ) -> "Text": """Construct a Text instance with a pre-applied styled. A style applied in this way won't be used to pad the text when it is justified. @@ -266,11 +340,12 @@ class Text(JupyterMixin): cls, *parts: Union[str, "Text", Tuple[str, StyleType]], style: Union[str, Style] = "", - justify: "JustifyMethod" = None, - overflow: "OverflowMethod" = None, - no_wrap: bool = None, + justify: Optional["JustifyMethod"] = None, + overflow: Optional["OverflowMethod"] = None, + no_wrap: Optional[bool] = None, end: str = "\n", tab_size: int = 8, + meta: Optional[Dict[str, Any]] = None, ) -> "Text": """Construct a text instance by combining a sequence of strings with optional styles. The positional arguments should be either strings, or a tuple of string + style. @@ -281,6 +356,7 @@ class Text(JupyterMixin): overflow (str, optional): Overflow method: "crop", "fold", "ellipsis". Defaults to None. end (str, optional): Character to end text with. Defaults to "\\\\n". tab_size (int): Number of spaces per tab, or ``None`` to use ``console.tab_size``. Defaults to 8. + meta (Dict[str, Any], optional). Meta data to apply to text, or None for no meta data. Default to None Returns: Text: A new text instance. @@ -300,6 +376,8 @@ class Text(JupyterMixin): append(part) else: append(*part) + if meta: + text.apply_meta(meta) return text @property @@ -357,7 +435,10 @@ class Text(JupyterMixin): return copy_self def stylize( - self, style: Union[str, Style], start: int = 0, end: Optional[int] = None + self, + style: Union[str, Style], + start: int = 0, + end: Optional[int] = None, ) -> None: """Apply a style to the text, or a portion of the text. @@ -380,6 +461,40 @@ class Text(JupyterMixin): return self._spans.append(Span(start, min(length, end), style)) + def apply_meta( + self, meta: Dict[str, Any], start: int = 0, end: Optional[int] = None + ) -> None: + """Apply meta data to the text, or a portion of the text. + + Args: + meta (Dict[str, Any]): A dict of meta information. + start (int): Start offset (negative indexing is supported). Defaults to 0. + end (Optional[int], optional): End offset (negative indexing is supported), or None for end of text. Defaults to None. + + """ + style = Style.from_meta(meta) + self.stylize(style, start=start, end=end) + + def on(self, meta: Optional[Dict[str, Any]] = None, **handlers: Any) -> "Text": + """Apply event handlers (used by Textual project). + + Example: + >>> from rich.text import Text + >>> text = Text("hello world") + >>> text.on(click="view.toggle('world')") + + Args: + meta (Dict[str, Any]): Mapping of meta information. + **handlers: Keyword args are prefixed with "@" to defined handlers. + + Returns: + Text: Self is returned to method may be chained. + """ + meta = {} if meta is None else meta + meta.update({f"@{key}": value for key, value in handlers.items()}) + self.stylize(Style.from_meta(meta)) + return self + def remove_suffix(self, suffix: str) -> None: """Remove a suffix if it exists. @@ -412,7 +527,7 @@ class Text(JupyterMixin): def highlight_regex( self, re_highlight: str, - style: Union[GetStyleCallable, StyleType] = None, + style: Optional[Union[GetStyleCallable, StyleType]] = None, *, style_prefix: str = "", ) -> int: @@ -506,13 +621,10 @@ class Text(JupyterMixin): def __rich_console__( self, console: "Console", options: "ConsoleOptions" ) -> Iterable[Segment]: - tab_size: int = console.tab_size or self.tab_size or 8 # type: ignore - justify = cast( - "JustifyMethod", self.justify or options.justify or DEFAULT_OVERFLOW - ) - overflow = cast( - "OverflowMethod", self.overflow or options.overflow or DEFAULT_OVERFLOW - ) + tab_size: int = console.tab_size or self.tab_size or 8 + justify = self.justify or options.justify or DEFAULT_JUSTIFY + + overflow = self.overflow or options.overflow or DEFAULT_OVERFLOW lines = self.wrap( console, @@ -635,7 +747,7 @@ class Text(JupyterMixin): new_text._length = offset return new_text - def expand_tabs(self, tab_size: int = None) -> None: + def expand_tabs(self, tab_size: Optional[int] = None) -> None: """Converts tabs to spaces. Args: @@ -774,7 +886,7 @@ class Text(JupyterMixin): self.pad_left(excess_space, character) def append( - self, text: Union["Text", str], style: Union[str, "Style"] = None + self, text: Union["Text", str], style: Optional[Union[str, "Style"]] = None ) -> "Text": """Add text with an optional style. @@ -836,7 +948,9 @@ class Text(JupyterMixin): self._length += len(text) return self - def append_tokens(self, tokens: Iterable[Tuple[str, Optional[StyleType]]]): + def append_tokens( + self, tokens: Iterable[Tuple[str, Optional[StyleType]]] + ) -> "Text": """Append iterable of str and style. Style may be a Style instance or a str style definition. Args: @@ -867,7 +981,7 @@ class Text(JupyterMixin): def split( self, - separator="\n", + separator: str = "\n", *, include_separator: bool = False, allow_blank: bool = False, @@ -919,6 +1033,7 @@ class Text(JupyterMixin): Lines: New RichText instances between offsets. """ _offsets = list(offsets) + if not _offsets: return Lines([self.copy()]) @@ -942,33 +1057,49 @@ class Text(JupyterMixin): ) if not self._spans: return new_lines - order = {span: span_index for span_index, span in enumerate(self._spans)} - span_stack = sorted(self._spans, key=attrgetter("start"), reverse=True) - pop = span_stack.pop - push = span_stack.append + _line_appends = [line._spans.append for line in new_lines._lines] + line_count = len(line_ranges) _Span = Span - get_order = order.__getitem__ - - for line, (start, end) in zip(new_lines, line_ranges): - if not span_stack: - break - append_span = line._spans.append - position = len(span_stack) - 1 - while span_stack[position].start < end: - span = pop(position) - add_span, remaining_span = span.split(end) - if remaining_span: - push(remaining_span) - order[remaining_span] = order[span] - span_start, span_end, span_style = add_span - line_span = _Span(span_start - start, span_end - start, span_style) - order[line_span] = order[span] - append_span(line_span) - position -= 1 - if position < 0 or not span_stack: - break # pragma: no cover - line._spans.sort(key=get_order) + + for span_start, span_end, style in self._spans: + + lower_bound = 0 + upper_bound = line_count + start_line_no = (lower_bound + upper_bound) // 2 + + while True: + line_start, line_end = line_ranges[start_line_no] + if span_start < line_start: + upper_bound = start_line_no - 1 + elif span_start > line_end: + lower_bound = start_line_no + 1 + else: + break + start_line_no = (lower_bound + upper_bound) // 2 + + if span_end < line_end: + end_line_no = start_line_no + else: + end_line_no = lower_bound = start_line_no + upper_bound = line_count + + while True: + line_start, line_end = line_ranges[end_line_no] + if span_end < line_start: + upper_bound = end_line_no - 1 + elif span_end > line_end: + lower_bound = end_line_no + 1 + else: + break + end_line_no = (lower_bound + upper_bound) // 2 + + for line_no in range(start_line_no, end_line_no + 1): + line_start, line_end = line_ranges[line_no] + new_start = max(0, span_start - line_start) + new_end = min(span_end - line_start, line_end - line_start) + if new_end > new_start: + _line_appends[line_no](_Span(new_start, new_end, style)) return new_lines @@ -993,10 +1124,10 @@ class Text(JupyterMixin): console: "Console", width: int, *, - justify: "JustifyMethod" = None, - overflow: "OverflowMethod" = None, + justify: Optional["JustifyMethod"] = None, + overflow: Optional["OverflowMethod"] = None, tab_size: int = 8, - no_wrap: bool = None, + no_wrap: Optional[bool] = None, ) -> Lines: """Word wrap the text. @@ -1012,10 +1143,9 @@ class Text(JupyterMixin): Returns: Lines: Number of lines. """ - wrap_justify = cast("JustifyMethod", justify or self.justify or DEFAULT_JUSTIFY) - wrap_overflow = cast( - "OverflowMethod", overflow or self.overflow or DEFAULT_OVERFLOW - ) + wrap_justify = justify or self.justify or DEFAULT_JUSTIFY + wrap_overflow = overflow or self.overflow or DEFAULT_OVERFLOW + no_wrap = pick_bool(no_wrap, self.no_wrap, False) or overflow == "ignore" lines = Lines() @@ -1077,7 +1207,7 @@ class Text(JupyterMixin): def with_indent_guides( self, - indent_size: int = None, + indent_size: Optional[int] = None, *, character: str = "│", style: StyleType = "dim green", @@ -1103,7 +1233,7 @@ class Text(JupyterMixin): new_lines: List[Text] = [] add_line = new_lines.append blank_lines = 0 - for line in text.split(): + for line in text.split(allow_blank=True): match = re_indent.match(line.plain) if not match or not match.group(2): blank_lines += 1 @@ -1134,6 +1264,7 @@ if __name__ == "__main__": # pragma: no cover text.highlight_words(["ipsum"], "italic") console = Console() + console.rule("justify='left'") console.print(text, style="red") console.print() diff --git a/libs/rich/theme.py b/libs/rich/theme.py index 68b1ef3d2..bfb3c7f82 100644 --- a/libs/rich/theme.py +++ b/libs/rich/theme.py @@ -1,5 +1,5 @@ import configparser -from typing import Dict, List, IO, Mapping +from typing import Dict, List, IO, Mapping, Optional from .default_styles import DEFAULT_STYLES from .style import Style, StyleType @@ -15,7 +15,9 @@ class Theme: styles: Dict[str, Style] - def __init__(self, styles: Mapping[str, StyleType] = None, inherit: bool = True): + def __init__( + self, styles: Optional[Mapping[str, StyleType]] = None, inherit: bool = True + ): self.styles = DEFAULT_STYLES.copy() if inherit else {} if styles is not None: self.styles.update( @@ -35,7 +37,7 @@ class Theme: @classmethod def from_file( - cls, config_file: IO[str], source: str = None, inherit: bool = True + cls, config_file: IO[str], source: Optional[str] = None, inherit: bool = True ) -> "Theme": """Load a theme from a text mode file. diff --git a/libs/rich/traceback.py b/libs/rich/traceback.py index c3f5c0eba..1d3b71ea3 100644 --- a/libs/rich/traceback.py +++ b/libs/rich/traceback.py @@ -5,8 +5,8 @@ import platform import sys from dataclasses import dataclass, field from traceback import walk_tb -from types import TracebackType -from typing import Any, Callable, Dict, Iterable, List, Optional, Type +from types import ModuleType, TracebackType +from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Type, Union from pygments.lexers import guess_lexer_for_filename from pygments.token import Comment, Keyword, Name, Number, Operator, String @@ -16,13 +16,7 @@ from pygments.token import Token from . import pretty from ._loop import loop_first, loop_last from .columns import Columns -from .console import ( - Console, - ConsoleOptions, - ConsoleRenderable, - RenderResult, - render_group, -) +from .console import Console, ConsoleOptions, ConsoleRenderable, RenderResult, group from .constrain import Constrain from .highlighter import RegexHighlighter, ReprHighlighter from .panel import Panel @@ -40,14 +34,16 @@ LOCALS_MAX_STRING = 80 def install( *, - console: Console = None, + console: Optional[Console] = None, width: Optional[int] = 100, extra_lines: int = 3, theme: Optional[str] = None, word_wrap: bool = False, show_locals: bool = False, indent_guides: bool = True, -) -> Callable: + suppress: Iterable[Union[str, ModuleType]] = (), + max_frames: int = 100, +) -> Callable[[Type[BaseException], BaseException, Optional[TracebackType]], Any]: """Install a rich traceback handler. Once installed, any tracebacks will be printed with syntax highlighting and rich formatting. @@ -62,6 +58,7 @@ def install( word_wrap (bool, optional): Enable word wrapping of long lines. Defaults to False. show_locals (bool, optional): Enable display of local variables. Defaults to False. indent_guides (bool, optional): Enable indent guides in code and locals. Defaults to True. + suppress (Sequence[Union[str, ModuleType]]): Optional sequence of modules or paths to exclude from traceback. Returns: Callable: The previous exception handler that was replaced. @@ -85,20 +82,24 @@ def install( word_wrap=word_wrap, show_locals=show_locals, indent_guides=indent_guides, + suppress=suppress, + max_frames=max_frames, ) ) - def ipy_excepthook_closure(ip) -> None: # pragma: no cover + def ipy_excepthook_closure(ip: Any) -> None: # pragma: no cover tb_data = {} # store information about showtraceback call default_showtraceback = ip.showtraceback # keep reference of default traceback - def ipy_show_traceback(*args, **kwargs) -> None: + def ipy_show_traceback(*args: Any, **kwargs: Any) -> None: """wrap the default ip.showtraceback to store info for ip._showtraceback""" nonlocal tb_data tb_data = kwargs default_showtraceback(*args, **kwargs) - def ipy_display_traceback(*args, is_syntax: bool = False, **kwargs) -> None: + def ipy_display_traceback( + *args: Any, is_syntax: bool = False, **kwargs: Any + ) -> None: """Internally called traceback from ip._showtraceback""" nonlocal tb_data exc_tuple = ip._get_exc_info() @@ -128,7 +129,7 @@ def install( ) try: # pragma: no cover - # if wihin ipython, use customized traceback + # if within ipython, use customized traceback ip = get_ipython() # type: ignore ipy_excepthook_closure(ip) return sys.excepthook @@ -190,6 +191,9 @@ class Traceback: locals_max_length (int, optional): Maximum length of containers before abbreviating, or None for no abbreviation. Defaults to 10. locals_max_string (int, optional): Maximum length of string before truncating, or None to disable. Defaults to 80. + suppress (Sequence[Union[str, ModuleType]]): Optional sequence of modules or paths to exclude from traceback. + max_frames (int): Maximum number of frames to show in a traceback, 0 for no maximum. Defaults to 100. + """ LEXERS = { @@ -202,7 +206,7 @@ class Traceback: def __init__( self, - trace: Trace = None, + trace: Optional[Trace] = None, width: Optional[int] = 100, extra_lines: int = 3, theme: Optional[str] = None, @@ -211,6 +215,8 @@ class Traceback: indent_guides: bool = True, locals_max_length: int = LOCALS_MAX_LENGTH, locals_max_string: int = LOCALS_MAX_STRING, + suppress: Iterable[Union[str, ModuleType]] = (), + max_frames: int = 100, ): if trace is None: exc_type, exc_value, traceback = sys.exc_info() @@ -231,10 +237,23 @@ class Traceback: self.locals_max_length = locals_max_length self.locals_max_string = locals_max_string + self.suppress: Sequence[str] = [] + for suppress_entity in suppress: + if not isinstance(suppress_entity, str): + assert ( + suppress_entity.__file__ is not None + ), f"{suppress_entity!r} must be a module with '__file__' attribute" + path = os.path.dirname(suppress_entity.__file__) + else: + path = suppress_entity + path = os.path.normpath(os.path.abspath(path)) + self.suppress.append(path) + self.max_frames = max(4, max_frames) if max_frames > 0 else 0 + @classmethod def from_exception( cls, - exc_type: Type, + exc_type: Type[Any], exc_value: BaseException, traceback: Optional[TracebackType], width: Optional[int] = 100, @@ -245,6 +264,8 @@ class Traceback: indent_guides: bool = True, locals_max_length: int = LOCALS_MAX_LENGTH, locals_max_string: int = LOCALS_MAX_STRING, + suppress: Iterable[Union[str, ModuleType]] = (), + max_frames: int = 100, ) -> "Traceback": """Create a traceback from exception info @@ -261,6 +282,8 @@ class Traceback: locals_max_length (int, optional): Maximum length of containers before abbreviating, or None for no abbreviation. Defaults to 10. locals_max_string (int, optional): Maximum length of string before truncating, or None to disable. Defaults to 80. + suppress (Iterable[Union[str, ModuleType]]): Optional sequence of modules or paths to exclude from traceback. + max_frames (int): Maximum number of frames to show in a traceback, 0 for no maximum. Defaults to 100. Returns: Traceback: A Traceback instance that may be printed. @@ -278,6 +301,8 @@ class Traceback: indent_guides=indent_guides, locals_max_length=locals_max_length, locals_max_string=locals_max_string, + suppress=suppress, + max_frames=max_frames, ) @classmethod @@ -357,6 +382,8 @@ class Traceback: else None, ) append(frame) + if "_rich_traceback_guard" in frame_summary.f_locals: + del stack.frames[:] cause = getattr(exc_value, "__cause__", None) if cause and cause.__traceback__: @@ -410,7 +437,8 @@ class Traceback: "scope.equals": token_style(Operator), "scope.key": token_style(Name), "scope.key.special": token_style(Name.Constant) + Style(dim=True), - } + }, + inherit=False, ) highlighter = ReprHighlighter() @@ -420,7 +448,7 @@ class Traceback: self._render_stack(stack), title="[traceback.title]Traceback [dim](most recent call last)", style=background_style, - border_style="traceback.border.syntax_error", + border_style="traceback.border", expand=True, padding=(0, 1), ) @@ -433,7 +461,7 @@ class Traceback: Panel( self._render_syntax_error(stack.syntax_error), style=background_style, - border_style="traceback.border", + border_style="traceback.border.syntax_error", expand=True, padding=(0, 1), width=self.width, @@ -444,11 +472,13 @@ class Traceback: (f"{stack.exc_type}: ", "traceback.exc_type"), highlighter(stack.syntax_error.msg), ) - else: + elif stack.exc_value: yield Text.assemble( (f"{stack.exc_type}: ", "traceback.exc_type"), highlighter(stack.exc_value), ) + else: + yield Text.assemble((f"{stack.exc_type}", "traceback.exc_type")) if not last: if stack.is_cause: @@ -460,7 +490,7 @@ class Traceback: "\n[i]During handling of the above exception, another exception occurred:\n", ) - @render_group() + @group() def _render_syntax_error(self, syntax_error: _SyntaxError) -> RenderResult: highlighter = ReprHighlighter() path_highlighter = PathHighlighter() @@ -475,7 +505,7 @@ class Traceback: syntax_error_text = highlighter(syntax_error.line.rstrip()) syntax_error_text.no_wrap = True offset = min(syntax_error.offset - 1, len(syntax_error_text)) - syntax_error_text.stylize("bold underline", offset, offset + 1) + syntax_error_text.stylize("bold underline", offset, offset) syntax_error_text += Text.from_markup( "\n" + " " * offset + "[traceback.offset]▲[/]", style="pygments.text", @@ -498,7 +528,7 @@ class Traceback: ) return lexer_name - @render_group() + @group() def _render_stack(self, stack: Stack) -> RenderResult: path_highlighter = PathHighlighter() theme = self.theme @@ -532,7 +562,33 @@ class Traceback: max_string=self.locals_max_string, ) - for first, frame in loop_first(stack.frames): + exclude_frames: Optional[range] = None + if self.max_frames != 0: + exclude_frames = range( + self.max_frames // 2, + len(stack.frames) - self.max_frames // 2, + ) + + excluded = False + for frame_index, frame in enumerate(stack.frames): + + if exclude_frames and frame_index in exclude_frames: + excluded = True + continue + + if excluded: + assert exclude_frames is not None + yield Text( + f"\n... {len(exclude_frames)} frames hidden ...", + justify="center", + style="traceback.error", + ) + excluded = False + + first = frame_index == 1 + frame_filename = frame.filename + suppressed = any(frame_filename.startswith(path) for path in self.suppress) + text = Text.assemble( path_highlighter(Text(frame.filename, style="pygments.string")), (":", "pygments.text"), @@ -547,41 +603,42 @@ class Traceback: if frame.filename.startswith("<"): yield from render_locals(frame) continue - try: - code = read_code(frame.filename) - lexer_name = self._guess_lexer(frame.filename, code) - syntax = Syntax( - code, - lexer_name, - theme=theme, - line_numbers=True, - line_range=( - frame.lineno - self.extra_lines, - frame.lineno + self.extra_lines, - ), - highlight_lines={frame.lineno}, - word_wrap=self.word_wrap, - code_width=88, - indent_guides=self.indent_guides, - dedent=False, - ) - yield "" - except Exception as error: - yield Text.assemble( - (f"\n{error}", "traceback.error"), - ) - else: - yield ( - Columns( - [ - syntax, - *render_locals(frame), - ], - padding=1, + if not suppressed: + try: + code = read_code(frame.filename) + lexer_name = self._guess_lexer(frame.filename, code) + syntax = Syntax( + code, + lexer_name, + theme=theme, + line_numbers=True, + line_range=( + frame.lineno - self.extra_lines, + frame.lineno + self.extra_lines, + ), + highlight_lines={frame.lineno}, + word_wrap=self.word_wrap, + code_width=88, + indent_guides=self.indent_guides, + dedent=False, + ) + yield "" + except Exception as error: + yield Text.assemble( + (f"\n{error}", "traceback.error"), + ) + else: + yield ( + Columns( + [ + syntax, + *render_locals(frame), + ], + padding=1, + ) + if frame.locals + else syntax ) - if frame.locals - else syntax - ) if __name__ == "__main__": # pragma: no cover @@ -591,12 +648,12 @@ if __name__ == "__main__": # pragma: no cover console = Console() import sys - def bar(a): # 这是对亚洲语言支持的测试。面对模棱两可的想法,拒绝猜测的诱惑 + def bar(a: Any) -> None: # 这是对亚洲语言支持的测试。面对模棱两可的想法,拒绝猜测的诱惑 one = 1 print(one / a) - def foo(a): - + def foo(a: Any) -> None: + _rich_traceback_guard = True zed = { "characters": { "Paul Atreides", @@ -608,7 +665,7 @@ if __name__ == "__main__": # pragma: no cover } bar(a) - def error(): + def error() -> None: try: try: diff --git a/libs/rich/tree.py b/libs/rich/tree.py index 5fc4487a8..66203e693 100644 --- a/libs/rich/tree.py +++ b/libs/rich/tree.py @@ -1,4 +1,4 @@ -from typing import Iterator, List, Tuple +from typing import Iterator, List, Optional, Tuple from ._loop import loop_first, loop_last from .console import Console, ConsoleOptions, RenderableType, RenderResult @@ -26,8 +26,9 @@ class Tree(JupyterMixin): *, style: StyleType = "tree", guide_style: StyleType = "tree.line", - expanded=True, - highlight=False, + expanded: bool = True, + highlight: bool = False, + hide_root: bool = False, ) -> None: self.label = label self.style = style @@ -35,15 +36,16 @@ class Tree(JupyterMixin): self.children: List[Tree] = [] self.expanded = expanded self.highlight = highlight + self.hide_root = hide_root def add( self, label: RenderableType, *, - style: StyleType = None, - guide_style: StyleType = None, - expanded=True, - highlight=False, + style: Optional[StyleType] = None, + guide_style: Optional[StyleType] = None, + expanded: bool = True, + highlight: bool = False, ) -> "Tree": """Add a child tree. @@ -105,6 +107,8 @@ class Tree(JupyterMixin): style_stack = StyleStack(get_style(self.style)) remove_guide_styles = Style(bold=False, underline2=False) + depth = 0 + while stack: stack_node = pop() try: @@ -123,7 +127,7 @@ class Tree(JupyterMixin): guide_style = guide_style_stack.current + get_style(node.guide_style) style = style_stack.current + get_style(node.style) - prefix = levels[1:] + prefix = levels[(2 if self.hide_root else 1) :] renderable_lines = console.render_lines( Styled(node.label, style), options.update( @@ -133,19 +137,21 @@ class Tree(JupyterMixin): height=None, ), ) - for first, line in loop_first(renderable_lines): - if prefix: - yield from _Segment.apply_style( - prefix, - style.background_style, - post_style=remove_guide_styles, - ) - yield from line - yield new_line - if first and prefix: - prefix[-1] = make_guide( - SPACE if last else CONTINUE, prefix[-1].style or null_style - ) + + if not (depth == 0 and self.hide_root): + for first, line in loop_first(renderable_lines): + if prefix: + yield from _Segment.apply_style( + prefix, + style.background_style, + post_style=remove_guide_styles, + ) + yield from line + yield new_line + if first and prefix: + prefix[-1] = make_guide( + SPACE if last else CONTINUE, prefix[-1].style or null_style + ) if node.expanded and node.children: levels[-1] = make_guide( @@ -157,6 +163,7 @@ class Tree(JupyterMixin): style_stack.push(get_style(node.style)) guide_style_stack.push(get_style(node.guide_style)) push(iter(loop_last(node.children))) + depth += 1 def __rich_measure__( self, console: "Console", options: "ConsoleOptions" @@ -188,7 +195,7 @@ class Tree(JupyterMixin): if __name__ == "__main__": # pragma: no cover - from rich.console import RenderGroup + from rich.console import Group from rich.markdown import Markdown from rich.panel import Panel from rich.syntax import Syntax @@ -222,21 +229,21 @@ class Segment(NamedTuple): """ ) - root = Tree("🌲 [b green]Rich Tree", highlight=True) + root = Tree("🌲 [b green]Rich Tree", highlight=True, hide_root=True) node = root.add(":file_folder: Renderables", guide_style="red") simple_node = node.add(":file_folder: [bold yellow]Atomic", guide_style="uu green") - simple_node.add(RenderGroup("📄 Syntax", syntax)) - simple_node.add(RenderGroup("📄 Markdown", Panel(markdown, border_style="green"))) + simple_node.add(Group("📄 Syntax", syntax)) + simple_node.add(Group("📄 Markdown", Panel(markdown, border_style="green"))) containers_node = node.add( ":file_folder: [bold magenta]Containers", guide_style="bold magenta" ) containers_node.expanded = True panel = Panel.fit("Just a panel", border_style="red") - containers_node.add(RenderGroup("📄 Panels", panel)) + containers_node.add(Group("📄 Panels", panel)) - containers_node.add(RenderGroup("📄 [b magenta]Table", table)) + containers_node.add(Group("📄 [b magenta]Table", table)) console = Console() console.print(root) |