aboutsummaryrefslogtreecommitdiffhomepage
path: root/libs/rich
diff options
context:
space:
mode:
authormorpheus65535 <[email protected]>2022-01-23 23:07:52 -0500
committermorpheus65535 <[email protected]>2022-01-23 23:07:52 -0500
commit0c3c5a02a75bc61b6bf6e303de20e11741d2afac (patch)
tree30ae1d524ffe5d54172b7a4a8445d90c3461e659 /libs/rich
parent36bf0d219d0432c20e6314e0ce752b36f4d88e3c (diff)
downloadbazarr-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')
-rw-r--r--libs/rich/__init__.py68
-rw-r--r--libs/rich/__main__.py33
-rw-r--r--libs/rich/_emoji_replace.py29
-rw-r--r--libs/rich/_extension.py10
-rw-r--r--libs/rich/_inspect.py29
-rw-r--r--libs/rich/_log_render.py16
-rw-r--r--libs/rich/_lru_cache.py2
-rw-r--r--libs/rich/_ratio.py13
-rw-r--r--libs/rich/_timer.py3
-rw-r--r--libs/rich/_windows.py13
-rw-r--r--libs/rich/align.py56
-rw-r--r--libs/rich/ansi.py2
-rw-r--r--libs/rich/bar.py4
-rw-r--r--libs/rich/box.py9
-rw-r--r--libs/rich/cells.py71
-rw-r--r--libs/rich/color.py26
-rw-r--r--libs/rich/color_triplet.py2
-rw-r--r--libs/rich/columns.py12
-rw-r--r--libs/rich/console.py520
-rw-r--r--libs/rich/containers.py12
-rw-r--r--libs/rich/control.py18
-rw-r--r--libs/rich/default_styles.py39
-rw-r--r--libs/rich/emoji.py34
-rw-r--r--libs/rich/file_proxy.py2
-rw-r--r--libs/rich/filesize.py37
-rw-r--r--libs/rich/highlighter.py24
-rw-r--r--libs/rich/json.py140
-rw-r--r--libs/rich/jupyter.py26
-rw-r--r--libs/rich/layout.py22
-rw-r--r--libs/rich/live.py96
-rw-r--r--libs/rich/live_render.py18
-rw-r--r--libs/rich/logging.py28
-rw-r--r--libs/rich/markdown.py22
-rw-r--r--libs/rich/markup.py99
-rw-r--r--libs/rich/measure.py19
-rw-r--r--libs/rich/padding.py10
-rw-r--r--libs/rich/pager.py4
-rw-r--r--libs/rich/panel.py52
-rw-r--r--libs/rich/pretty.py429
-rw-r--r--libs/rich/progress.py84
-rw-r--r--libs/rich/progress_bar.py6
-rw-r--r--libs/rich/prompt.py36
-rw-r--r--libs/rich/protocol.py34
-rw-r--r--libs/rich/repr.py157
-rw-r--r--libs/rich/scope.py10
-rw-r--r--libs/rich/screen.py25
-rw-r--r--libs/rich/segment.py434
-rw-r--r--libs/rich/spinner.py82
-rw-r--r--libs/rich/status.py43
-rw-r--r--libs/rich/style.py216
-rw-r--r--libs/rich/syntax.py79
-rw-r--r--libs/rich/table.py243
-rw-r--r--libs/rich/tabulate.py10
-rw-r--r--libs/rich/terminal_theme.py4
-rw-r--r--libs/rich/text.py261
-rw-r--r--libs/rich/theme.py8
-rw-r--r--libs/rich/traceback.py183
-rw-r--r--libs/rich/tree.py61
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
-
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)