tabs to spaces, Console.markup applies to Table

This commit is contained in:
Will McGugan 2020-03-01 14:43:46 +00:00
parent 9529453cce
commit 2a53a15284
12 changed files with 181 additions and 129 deletions

View File

@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 401 KiB

View File

@ -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 <willmcgugan@gmail.com>"]
license = "MIT"

View File

@ -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)

View File

@ -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

View File

@ -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.

View File

@ -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)

View File

@ -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)

View File

@ -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())

View File

@ -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")

View File

@ -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: