diff --git a/CHANGELOG.md b/CHANGELOG.md index 8770187d..59f2662a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.6.0] - Unreleased + +### Added + +- Added tab_size to Console and Text +- Added protocol.is_renderable for runtime check + +### Changed + +- Console.markup attribute now effects Table +- SeparatedConsoleRenderable and RichCast types + +### Fixed + +- Fixed tabs breaking rendering by converting to spaces + ## [0.5.0] - 2020-02-23 ### Changed diff --git a/imgs/traceback.png b/imgs/traceback.png deleted file mode 100644 index a4255f89..00000000 Binary files a/imgs/traceback.png and /dev/null differ diff --git a/imgs/traceback_windows.png b/imgs/traceback_windows.png deleted file mode 100644 index 589a21cf..00000000 Binary files a/imgs/traceback_windows.png and /dev/null differ diff --git a/pyproject.toml b/pyproject.toml index 50ac1544..83c75643 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ name = "rich" homepage = "https://github.com/willmcgugan/rich" documentation = "https://rich.readthedocs.io/en/latest/" -version = "0.5.0" +version = "0.6.0" description = "Render rich text, tables, syntax highlighting, markdown and more to the terminal" authors = ["Will McGugan "] license = "MIT" diff --git a/rich/console.py b/rich/console.py index 8b16419e..b5263888 100644 --- a/rich/console.py +++ b/rich/console.py @@ -108,18 +108,29 @@ class ConsoleOptions: return options +@runtime_checkable +class RichCast(Protocol): + """An object that may be 'cast' to a console renderable.""" + + def __rich__(self) -> Union["ConsoleRenderable", str]: # pragma: no cover + ... + + @runtime_checkable class ConsoleRenderable(Protocol): """An object that supports the console protocol.""" def __console__( self, console: "Console", options: "ConsoleOptions" - ) -> Iterable[Union["ConsoleRenderable", Segment, str]]: # pragma: no cover + ) -> "RenderResult": # pragma: no cover ... -RenderableType = Union[ConsoleRenderable, Segment, str] -RenderResult = Iterable[RenderableType] +"""A type that may be rendered by Console.""" +RenderableType = Union[ConsoleRenderable, RichCast, str] + +"""The result of calling a __console__ method.""" +RenderResult = Iterable[Union[RenderableType, Segment]] _null_highlighter = NullHighlighter() @@ -246,6 +257,7 @@ class Console: file: IO = None, width: int = None, height: int = None, + tab_size: int = 8, record: bool = False, markup: bool = True, log_time: bool = True, @@ -259,6 +271,7 @@ class Console: self.file = file or sys.stdout self._width = width self._height = height + self.tab_size = tab_size self.record = record self._markup = markup @@ -410,7 +423,9 @@ class Console: self._check_buffer() def _render( - self, renderable: RenderableType, options: Optional[ConsoleOptions] + self, + renderable: Union[RenderableType, Segment], + options: Optional[ConsoleOptions], ) -> Iterable[Segment]: """Render an object in to an iterable of `Segment` instances. @@ -425,17 +440,15 @@ class Console: Returns: Iterable[Segment]: An iterable of segments that may be rendered. """ - render_iterable: Iterable[RenderableType] - render_options = options or self.options + render_iterable: RenderResult if isinstance(renderable, Segment): yield renderable return - elif isinstance(renderable, ConsoleRenderable): + render_options = options or self.options + if isinstance(renderable, ConsoleRenderable): render_iterable = renderable.__console__(self, render_options) elif isinstance(renderable, str): - from .text import Text - - yield from self._render(Text(renderable), render_options) + yield from self.render(self.render_str(renderable), render_options) return else: raise errors.NotRenderableError( @@ -522,20 +535,22 @@ class Console: ) return lines - def render_str(self, text: str) -> "Text": + def render_str(self, text: str, style: Union[str, Style] = "") -> "Text": """Convert a string to a Text instance. Args: text (str): Text to render. - + style (Union[str, Style], optional): Style to apply to rendered text. Returns: ConsoleRenderable: Renderable object. """ if self._markup: - return markup.render(text) + return markup.render(text, style=style) - return markup.render_text(text) + from .text import Text + + return Text(text, style=style) def _get_style(self, name: str) -> Optional[Style]: """Get a named style, or `None` if it doesn't exist. @@ -681,10 +696,10 @@ class Console: check_text() append(renderable) elif isinstance(renderable, str): - render_str = renderable + renderable_str = renderable if emoji: - render_str = _emoji_replace(render_str) - render_text = self.render_str(render_str) + renderable_str = _emoji_replace(renderable_str) + render_text = self.render_str(renderable_str) append_text(_highlighter(render_text)) elif isinstance(renderable, Text): append_text(renderable) diff --git a/rich/constrain.py b/rich/constrain.py index dcf96044..1bbd6fea 100644 --- a/rich/constrain.py +++ b/rich/constrain.py @@ -4,15 +4,16 @@ from .console import Console, ConsoleOptions, ConsoleRenderable, RenderResult class Constrain: + """Constrain the width of a renderable to a given number of characters. + + Args: + renderable (ConsoleRenderable): A renderable object. + width (int, optional): The maximum width (in characters) to render. Defaults to 80. + """ + def __init__( self, renderable: ConsoleRenderable, width: Optional[int] = 80 ) -> None: - """Constrain the width of a renderable to a given number of characters. - - Args: - renderable (ConsoleRenderable): A renderable object. - width (int, optional): The maximum width (in characters) to render. Defaults to 80. - """ self.renderable = renderable self.width = width diff --git a/rich/markup.py b/rich/markup.py index f500354c..c29e66e3 100644 --- a/rich/markup.py +++ b/rich/markup.py @@ -59,11 +59,6 @@ def _parse(markup: str) -> Iterable[Tuple[Optional[str], Optional[str]]]: yield markup[position:], None -def render_text(markup: str, style: Union[str, Style] = "") -> Text: - """Convert markup to Text instance.""" - return Text(markup, style=style) - - def render(markup: str, style: Union[str, Style] = "") -> Text: """Render console markup in to a Text instance. diff --git a/rich/padding.py b/rich/padding.py index 81bad8a5..92768668 100644 --- a/rich/padding.py +++ b/rich/padding.py @@ -45,10 +45,6 @@ class Padding: if len(pad) == 2: pad_top, pad_right = cast(Tuple[int, int], pad) return (pad_top, pad_right, pad_top, pad_right) - if len(pad) == 3: - raise ValueError( - f"1, 2 or 4 integers required for padding; {len(pad)} given" - ) if len(pad) == 4: top, right, bottom, left = cast(Tuple[int, int, int, int], pad) return (top, right, bottom, left) diff --git a/rich/render_width.py b/rich/render_width.py index c1b937df..c2cd1c56 100644 --- a/rich/render_width.py +++ b/rich/render_width.py @@ -1,5 +1,5 @@ from operator import itemgetter -from typing import Iterable, NamedTuple, TYPE_CHECKING +from typing import Iterable, NamedTuple, TYPE_CHECKING, Union from . import errors from .segment import Segment @@ -37,7 +37,9 @@ class RenderWidth(NamedTuple): return RenderWidth(min(minimum, width), min(maximum, width)) @classmethod - def get(cls, renderable: "RenderableType", max_width: int) -> "RenderWidth": + def get( + cls, renderable: Union["RenderableType", "Segment"], max_width: int + ) -> "RenderWidth": """Get desired width for a renderable.""" if hasattr(renderable, "__console__"): get_console_width = getattr(renderable, "__console_width__", None) diff --git a/rich/syntax.py b/rich/syntax.py index a6be2683..aee86b40 100644 --- a/rich/syntax.py +++ b/rich/syntax.py @@ -16,7 +16,7 @@ from .text import Text from ._tools import iter_first WINDOWS = platform.system() == "Windows" -DEFAULT_THEME = "dark" +DEFAULT_THEME = "monokai" class Syntax: @@ -31,6 +31,8 @@ class Syntax: start_line (int, optional): Starting number for line numbers. Defaults to 1. line_range (Tuple[int, int], optional): If given should be a tuple of the start and end line to render. highlight_lines (Set[int]): A set of line numbers to highlight. + code_width: Width of code to render (not including line numbers), or ``None`` to use all available width. + tab_size (int, optional): Size of tabs. Defaults to 4. """ def __init__( @@ -45,6 +47,7 @@ class Syntax: line_range: Tuple[int, int] = None, highlight_lines: Set[int] = None, code_width: Optional[int] = None, + tab_size: int = 4, ) -> None: self.code = code self.lexer_name = lexer_name @@ -54,6 +57,7 @@ class Syntax: self.line_range = line_range self.highlight_lines = highlight_lines or set() self.code_width = code_width + self.tab_size = tab_size self._style_cache: Dict[Any, Style] = {} if not isinstance(theme, str) and issubclass(theme, PygmentsStyle): @@ -76,6 +80,7 @@ class Syntax: start_line: int = 1, highlight_lines: Set[int] = None, code_width: Optional[int] = None, + tab_size: int = 4, ) -> "Syntax": """Construct a Syntax object from a file. @@ -88,6 +93,8 @@ class Syntax: start_line (int, optional): Starting number for line numbers. Defaults to 1. line_range (Tuple[int, int], optional): If given should be a tuple of the start and end line to render. highlight_lines (Set[int]): A set of line numbers to highlight. + code_width: Width of code to render (not including line numbers), or ``None`` to use all available width. + tab_size (int, optional): Size of tabs. Defaults to 4. Returns: [Syntax]: A Syntax object that may be printed to the console @@ -139,8 +146,10 @@ class Syntax: try: lexer = get_lexer_by_name(lexer_name) except ClassNotFound: - return Text(self.code, style=default_style) - text = Text(style=default_style) + return Text( + self.code, justify="left", style=default_style, tab_size=self.tab_size + ) + text = Text(justify="left", style=default_style, tab_size=self.tab_size) append = text.append _get_theme_style = self._get_theme_style for token_type, token in lexer.get_tokens(self.code): @@ -257,32 +266,11 @@ class Syntax: if __name__ == "__main__": # pragma: no cover - CODE = r""" -def __init__(self): - self.b = self. - """ - # syntax = Syntax(CODE, "python", dedent=True, line_numbers=True, start_line=990) + import sys + from rich.console import Console - from time import time + console = Console() - syntax = Syntax(CODE, "python", line_numbers=False, theme="monokai") - syntax = Syntax.from_path( - "rich/segment.py", - theme="fruity", - line_numbers=True, - # line_range=(190, 300), - highlight_lines={194}, - ) - console = Console(record=True) - start = time() + syntax = Syntax.from_path(sys.argv[1]) console.print(syntax) - elapsed = int((time() - start) * 1000) - print(f"{elapsed}ms") - - # print(Color.downgrade.cache_info()) - # print(Color.parse.cache_info()) - # print(Color.get_ansi_codes.cache_info()) - - # print(Style.parse.cache_info()) - diff --git a/rich/table.py b/rich/table.py index 02e00476..d24548c3 100644 --- a/rich/table.py +++ b/rich/table.py @@ -24,8 +24,11 @@ if TYPE_CHECKING: RenderableType, RenderResult, ) + from .containers import Lines + from . import errors from .padding import Padding, PaddingDimensions +from .protocol import is_renderable from .segment import Segment from .style import Style from .text import Text @@ -37,8 +40,8 @@ from ._tools import iter_first_last, iter_first, iter_last, ratio_divide class Column: """Defines a column in a table.""" - header: Union[str, "ConsoleRenderable"] = "" - footer: Union[str, "ConsoleRenderable"] = "" + header: "RenderableType" = "" + footer: "RenderableType" = "" header_style: Union[str, Style] = "table.header" footer_style: Union[str, Style] = "table.footer" style: Union[str, Style] = "none" @@ -46,10 +49,10 @@ class Column: width: Optional[int] = None ratio: Optional[int] = None no_wrap: bool = False - _cells: List["ConsoleRenderable"] = field(default_factory=list) + _cells: List["RenderableType"] = field(default_factory=list) @property - def cells(self) -> Iterable["ConsoleRenderable"]: + def cells(self) -> Iterable["RenderableType"]: """Get all cells in the column, not including header.""" yield from self._cells @@ -58,27 +61,11 @@ class Column: """Check if this column is flexible.""" return self.ratio is not None - @property - def header_renderable(self) -> "ConsoleRenderable": - return ( - Text.from_markup(self.header, style=self.header_style or "") - if isinstance(self.header, str) - else self.header - ) - - @property - def footer_renderable(self) -> "ConsoleRenderable": - return ( - Text.from_markup(self.footer, style=self.footer_style or "") - if isinstance(self.footer, str) - else self.footer - ) - class _Cell(NamedTuple): style: Union[str, Style] - renderable: "ConsoleRenderable" + renderable: "RenderableType" class Table: @@ -202,7 +189,7 @@ class Table: ) self.columns.append(column) - def add_row(self, *renderables: Optional[Union[str, "ConsoleRenderable"]]) -> None: + def add_row(self, *renderables: Optional["RenderableType"]) -> None: """Add a row of renderables. Raises: @@ -210,12 +197,10 @@ class Table: """ from .console import ConsoleRenderable - def add_cell(column: Column, renderable: ConsoleRenderable): + def add_cell(column: Column, renderable: "RenderableType") -> None: column._cells.append(renderable) - cell_renderables: List[Optional[Union[str, ConsoleRenderable]]] = list( - renderables - ) + cell_renderables: List[Optional["RenderableType"]] = list(renderables) columns = self.columns if len(cell_renderables) < len(columns): @@ -231,12 +216,10 @@ class Table: self.columns.append(column) else: column = columns[index] - if isinstance(renderable, ConsoleRenderable): + if renderable is None: + add_cell(column, "") + elif is_renderable(renderable): add_cell(column, renderable) - elif renderable is None: - add_cell(column, Text("")) - elif isinstance(renderable, str): - add_cell(column, Text.from_markup(renderable)) else: raise errors.NotRenderableError( f"unable to render {renderable!r}; str or object with a __console__ method is required" @@ -257,32 +240,24 @@ class Table: max_width -= 2 widths = self._calculate_column_widths(max_width) table_width = sum(widths) + len(self.columns) + (2 if self.box else 0) - yield from self.render_title(table_width) + + def render_annotation( + text: Union[Text, str], style: Union[str, Style] + ) -> "Lines": + if isinstance(text, Text): + render_text = text + else: + render_text = console.render_str(text, style=style) + return render_text.wrap(table_width, "center") + + yield render_annotation( + self.title or "", style=Style.pick_first(self.title_style, "table.title") + ) yield from self._render(console, options, widths) - yield from self.render_caption(table_width) - - def render_title(self, table_width: int) -> "RenderResult": - """Render the title. Override if you want to render the title differently.""" - if self.title: - if isinstance(self.title, str): - title_text = Text.from_markup( - self.title, style=Style.pick_first(self.title_style, "table.title") - ) - else: - title_text = self.title - yield title_text.wrap(table_width, "center") - - def render_caption(self, table_width: int) -> "RenderResult": - """Render the caption. Override if you want to render the caption differently.""" - if self.caption: - if isinstance(self.caption, str): - caption_text = Text.from_markup( - self.caption, - style=Style.pick_first(self.caption_style, "table.caption"), - ) - else: - caption_text = self.caption - yield caption_text.wrap(table_width, "center") + yield render_annotation( + self.caption or "", + style=Style.pick_first(self.caption_style, "table.caption"), + ) def _calculate_column_widths(self, max_width: int) -> List[int]: """Calculate the widths of each column.""" @@ -342,7 +317,7 @@ class Table: first = column_index == 0 last = column_index == len(self.columns) - 1 - def add_padding(renderable: "ConsoleRenderable") -> "ConsoleRenderable": + def add_padding(renderable: "RenderableType") -> "RenderableType": if not any_padding: return renderable top, right, bottom, left = padding @@ -354,11 +329,11 @@ class Table: return Padding(renderable, (top, right, bottom, left)) if self.show_header: - yield _Cell(column.header_style, add_padding(column.header_renderable)) + yield _Cell(column.header_style, add_padding(column.header)) for cell in column.cells: yield _Cell(column.style, add_padding(cell)) if self.show_footer: - yield _Cell(column.footer_style, add_padding(column.footer_renderable)) + yield _Cell(column.footer_style, add_padding(column.footer)) def _measure_column( self, column_index: int, column: Column, max_width: int @@ -478,7 +453,7 @@ if __name__ == "__main__": # pragma: no cover from .console import Console from . import box - c = Console() + c = Console(markup=False) table = Table( Column( "Foo", footer=Text("Total", justify="right"), footer_style="bold", ratio=1 @@ -494,7 +469,7 @@ if __name__ == "__main__": # pragma: no cover # table.columns[0].width = 50 # table.columns[1].ratio = 1 - table.add_row("Hello, World! " * 3, "cake" * 10) + table.add_row("Hello, [b]World[/b]! " * 3, "cake" * 10) from .markdown import Markdown table.add_row(Markdown("# This is *Markdown*!"), "More text", "Hello WOrld") diff --git a/rich/text.py b/rich/text.py index 7fcdc726..125351f2 100644 --- a/rich/text.py +++ b/rich/text.py @@ -2,6 +2,7 @@ from operator import itemgetter import re from typing import ( Any, + cast, Dict, Iterable, NamedTuple, @@ -91,6 +92,7 @@ class Text: style (Union[str, Style], optional): Base style for text. Defaults to "". justify (str, optional): Default alignment for text, "left", "center", "full" or "right". Defaults to None. end (str, optional): Character to end text with. Defaults to "\n". + tab_size (int, optional): Number of spaces per tab, or ``None`` to use ``console.tab_size``. Defaults to None. """ def __init__( @@ -99,12 +101,13 @@ class Text: style: Union[str, Style] = "", justify: "JustifyValues" = None, end: str = "\n", + tab_size: int = None, ) -> None: - self._text: List[str] = [text] if text else [] self.style = style self.justify = justify self.end = end + self.tab_size = tab_size self._spans: List[Span] = [] self._length: int = len(text) @@ -132,6 +135,13 @@ class Text: return NotImplemented return self.text == other.text and self._spans == other._spans + def __contains__(self, other: object) -> bool: + if isinstance(other, str): + return other in self.text + elif isinstance(other, Text): + return other.text in self.text + return False + @classmethod def from_markup(cls, text: str, style: Union[str, Style] = "") -> "Text": """Create Text instance from markup. @@ -153,8 +163,9 @@ class Text: style: Union[str, Style] = "", justify: "JustifyValues" = None, end: str = "\n", + tab_size: Optional[int] = None, ) -> "Text": - text = cls(style=style, justify=justify, end=end) + text = cls(style=style, justify=justify, end=end, tab_size=tab_size) append = text.append for part in parts: append(*part) @@ -182,7 +193,11 @@ class Text: """Return a copy of this instance.""" self.text copy_self = Text( - self.text, style=self.style, justify=self.justify, end=self.end, + self.text, + style=self.style, + justify=self.justify, + end=self.end, + tab_size=self.tab_size, ) copy_self._spans[:] = self._spans[:] return copy_self @@ -281,7 +296,21 @@ class Text: def __console__( self, console: "Console", options: "ConsoleOptions" ) -> Iterable[Segment]: - lines = self.wrap(options.max_width, justify=self.justify or options.justify) + # TODO: Why does mypy give "error: Cannot determine type of 'tab_size'"" ? + if self.tab_size is None: + tab_size = console.tab_size # type: ignore + else: + tab_size = self.tab_size + + # if self.tab_size is None: + # tab_size = console.tab_size + # else: + # tab_size = self.tab_size + lines = self.wrap( + options.max_width, + justify=self.justify or options.justify, + tab_size=tab_size, + ) all_lines = Text("\n").join(lines) yield from self._render_line(all_lines, console, options) @@ -373,6 +402,37 @@ class Text: append(self) return new_text + def tabs_to_spaces(self, tab_size: int = 8) -> "Text": + """Get a new string with tabs converted to spaces. + + Args: + tab_size (int, optional): Size of tabs. Defaults to 8. + + Returns: + Text: A new instance with tabs replaces by spaces. + """ + if "\t" not in self.text: + return self.copy() + parts = self.split("\t", include_separator=True) + pos = 0 + result = Text( + style=self.style, justify=self.justify, end=self.end, tab_size=self.tab_size + ) + append = result.append + + for part in parts: + if part.text.endswith("\t"): + part._text = [part.text[:-1] + " "] + append(part) + pos += len(part) + spaces = tab_size - ((pos - 1) % tab_size) - 1 + if spaces: + append(" " * spaces, self.style) + pos += spaces + else: + append(part) + return result + def _trim_spans(self) -> None: """Remove or modify any spans that are over the end of the text.""" new_length = self._length @@ -537,7 +597,9 @@ class Text: """Remove a number of characters from the end of the text.""" self.text = self.text[:-amount] - def wrap(self, width: int, justify: "JustifyValues" = "left") -> Lines: + def wrap( + self, width: int, justify: "JustifyValues" = "left", tab_size: int = 8 + ) -> Lines: """Word wrap the text. Args: @@ -550,6 +612,8 @@ class Text: lines: Lines = Lines() for line in self.split(): + if "\t" in line: + line = line.tabs_to_spaces(tab_size) offsets = divide_line(str(line), width) new_lines = line.divide(offsets) if justify: