From a6db216788e082a4c5441713afd265dddea2f64e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 15 Nov 2019 21:47:29 +0000 Subject: [PATCH] functionality update --- .gitignore | 1 + rich/_stack.py | 15 ++ rich/color.py | 7 +- rich/console.py | 184 ++++++++++++++++------ rich/default_styles.py | 84 +++++----- rich/markdown.py | 131 +++++++++++----- rich/style.py | 21 ++- rich/styled_text.py | 14 ++ rich/text.py | 340 ++++++++++++++++++++++++++++++++++------- 9 files changed, 615 insertions(+), 182 deletions(-) create mode 100644 rich/_stack.py create mode 100644 rich/styled_text.py diff --git a/.gitignore b/.gitignore index fddc0135..607b6192 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .DS_Store +.vscode # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/rich/_stack.py b/rich/_stack.py new file mode 100644 index 00000000..f09dcef0 --- /dev/null +++ b/rich/_stack.py @@ -0,0 +1,15 @@ +from typing import List, TypeVar + +T = TypeVar("T") + + +class Stack(List[T]): + """A small shim over builtin list.""" + + @property + def top(self) -> T: + """Get top of stack.""" + return self[-1] + + def push(self, item: T) -> None: + self.append(item) diff --git a/rich/color.py b/rich/color.py index 420d17c3..a7c47764 100644 --- a/rich/color.py +++ b/rich/color.py @@ -419,14 +419,15 @@ class Color(NamedTuple): def get_ansi_codes(self, foreground: bool = True) -> List[str]: """Get the ANSI escape codes for this color.""" - if self.type == ColorType.DEFAULT: + _type = self.type + if _type == ColorType.DEFAULT: return ["39" if foreground else "49"] - elif self.type == ColorType.STANDARD: + elif _type == ColorType.STANDARD: assert self.number is not None return [str(30 + self.number if foreground else 40 + self.number)] - elif self.type == ColorType.EIGHT_BIT: + elif _type == ColorType.EIGHT_BIT: assert self.number is not None return ["38" if foreground else "48", "5", str(self.number)] diff --git a/rich/console.py b/rich/console.py index b32129fa..b7f9a1b4 100644 --- a/rich/console.py +++ b/rich/console.py @@ -25,6 +25,7 @@ from typing import ( from .default_styles import DEFAULT_STYLES from . import errors from .style import Style +from .styled_text import StyledText @dataclass @@ -32,15 +33,11 @@ class ConsoleOptions: """Options for __console__ method.""" max_width: int + is_terminal: bool + encoding: str min_width: int = 1 -@runtime_checkable -class SupportsConsole(Protocol): - def __console__(self) -> StyledText: - ... - - class SupportsStr(Protocol): def __str__(self) -> str: ... @@ -50,21 +47,14 @@ class SupportsStr(Protocol): class ConsoleRenderable(Protocol): """An object that supports the console protocol.""" - def __console_render__( + def __console__( self, console: Console, options: ConsoleOptions - ) -> Iterable[Union[SupportsConsole, StyledText]]: + ) -> Iterable[Union[ConsoleRenderable, StyledText]]: ... -class StyledText(NamedTuple): - """A piece of text with associated style.""" - - text: str - style: Optional[Style] = None - - def __repr__(self) -> str: - """Simplified repr.""" - return f"StyleText({self.text!r}, {self.style!r})" +RenderableType = Union[ConsoleRenderable, SupportsStr] +RenderResult = Iterable[Union[ConsoleRenderable, StyledText]] class ConsoleDimensions(NamedTuple): @@ -82,13 +72,13 @@ class StyleContext: self.style = style def __enter__(self) -> Console: - self.console._enter_buffer() self.console.push_style(self.style) + self.console._enter_buffer() return self.console def __exit__(self, exc_type, exc_value, traceback) -> None: - self.console.pop_style() self.console._exit_buffer() + self.console.pop_style() class Console: @@ -96,14 +86,27 @@ class Console: default_style = Style.reset() - def __init__(self, styles: Dict[str, Style] = DEFAULT_STYLES, file: IO = None): + def __init__( + self, + styles: Dict[str, Style] = DEFAULT_STYLES, + file: IO = None, + width: int = None, + height: int = None, + markup: str = "markdown", + ): self._styles = ChainMap(styles) self.file = file or sys.stdout - self.style_stack: List[Style] = [Style()] + self._width = width + self._height = height + self._markup = markup + self.buffer: List[StyledText] = [] - self.current_style = Style() self._buffer_index = 0 + default_style = Style() + self.style_stack: List[Style] = [default_style] + self.current_style = default_style + # def push_styles(self, styles: Dict[str, Style]) -> None: # """Push a new set of styles on to the style stack. @@ -132,6 +135,71 @@ class Console: def __exit__(self, exc_type, exc_value, traceback) -> None: self._exit_buffer() + @property + def encoding(self) -> str: + """Get the encoding of the console file. + + Returns: + str: A standard encoding string. + """ + return getattr(self.file, "encoding", "utf-8") + + @property + def is_terminal(self) -> bool: + """Check if the console is writing to a terminal. + + Returns: + bool: True if the console writting to a device capable of + understanding terminal codes, otherwise False. + """ + isatty = getattr(self.file, "isatty", None) + return False if isatty is None else isatty() + + @property + def options(self) -> ConsoleOptions: + """Get default console options.""" + return ConsoleOptions( + max_width=self.width, encoding=self.encoding, is_terminal=self.is_terminal + ) + + def render( + self, renderable: RenderableType, options: ConsoleOptions + ) -> Iterable[StyledText]: + """Render an object in to an iterable of `StyledText` instances. + + This method contains the logic for rendering objects with the console protocol. + You are unlikely to need to use it directly, unless you are extending the library. + + + Args: + renderable (RenderableType): An object supporting the console protocol, or + an object that may be converted to a string. + options (ConsoleOptions, optional): An options objects. Defaults to None. + + Returns: + Iterable[StyledText]: An iterable of styled text that may be rendered. + """ + + if isinstance(renderable, ConsoleRenderable): + render_iterable = renderable.__console__(self, options) + else: + render_iterable = self.render_str(str(renderable), options) + + for render_output in render_iterable: + if isinstance(render_output, StyledText): + yield render_output + else: + yield from self.render(render_output, options) + + def render_str(self, text: str, options: ConsoleOptions) -> Iterable[StyledText]: + """Render a string.""" + if self._markup == "markdown": + from .markdown import Markdown + + yield Markdown(text) + else: + yield StyledText(text, self.current_style) + def get_style(self, name: str) -> Optional[Style]: """Get a named style, or `None` if it doesn't exist. @@ -143,6 +211,21 @@ class Console: """ return self._styles.get(name, None) + def parse_style(self, name: str) -> Optional[Style]: + """Get a named style, or parse a style definition. + + Args: + name (str): The name of a style. + + Returns: + Optional[Style]: A Style object or `None` if it couldn't be found / parsed. + + """ + try: + return self._styles.get(name, None) or Style.parse(name) + except errors.StyleSyntaxError: + return None + def push_style(self, style: Union[str, Style]) -> None: """Push a style on to the stack. @@ -202,27 +285,22 @@ class Console: Returns: None: """ - write_style = self.current_style or self.get_style(style) + write_style = self.current_style or self.get_style(style or "none") self.buffer.append(StyledText(text, write_style)) self._check_buffer() - def print(self, *objects: Union[ConsoleRenderable, SupportsStr]) -> None: - options = ConsoleOptions(max_width=self.width) + def print(self, *objects: RenderableType, sep: str = " ", end="\n") -> None: + options = self.options buffer_append = self.buffer.append + buffer_extend = self.buffer.extend + separator = StyledText(sep) with self: - for console_object in objects: - if isinstance(console_object, ConsoleRenderable): - render = console_object.__console_render__(self, options) - for console_output in render: - if isinstance(console_output, SupportsConsole): - styled_text = console_output.__console__() - else: - styled_text = console_output - buffer_append(styled_text) - else: - styled_text = StyledText(str(console_object), None) - buffer_append(styled_text) - buffer_append(StyledText("\n")) + last_index = len(objects) - 1 + for index, console_object in enumerate(objects): + buffer_extend(self.render(console_object, options)) + if sep and index != last_index: + buffer_append(separator) + buffer_append(StyledText(end)) def _check_buffer(self) -> None: """Check if the buffer may be rendered.""" @@ -236,10 +314,12 @@ class Console: append = output.append for text, style in self.buffer: if style: + style = self.current_style.apply(style) append(style.render(text, reset=True)) else: append(text) rendered = "".join(output) + del self.buffer[:] return rendered @@ -250,8 +330,14 @@ class Console: Returns: ConsoleDimensions: A named tuple containing the dimensions. """ + if self._width is not None and self._height is not None: + return ConsoleDimensions(self._width, self._height) + width, height = shutil.get_terminal_size() - return ConsoleDimensions(width, height) + return ConsoleDimensions( + width if self._width is None else self._width, + height if self._height is None else self._height, + ) @property def width(self) -> int: @@ -260,7 +346,7 @@ class Console: Returns: int: The width (in characters) of the console. """ - width, _ = shutil.get_terminal_size() + width, _ = self.size return width # def write(self, console_object: Any) -> Console: @@ -291,16 +377,20 @@ class Console: if __name__ == "__main__": - console = Console() + console = Console(width=80) # console.write_text("Hello", style="bold magenta on white", end="").write_text( # " World!", "italic blue" # ) # "[b]This is bold [style not bold]This is not[/style] this is[/b]" - console.write("Hello ") - with console.style("bold blue"): - console.write("World ") - with console.style("italic"): - console.write("in style") - console.write("!") + # console.write("Hello ") + # with console.style("bold blue"): + # console.write("World ") + # with console.style("italic"): + # console.write("in style") + # console.write("!") + + with console.style("dim on black"): + console.print("**Hello**, *World*!") + console.print("Hello, *World*!") diff --git a/rich/default_styles.py b/rich/default_styles.py index 4e67efe1..f404d0a6 100644 --- a/rich/default_styles.py +++ b/rich/default_styles.py @@ -3,49 +3,53 @@ from typing import Dict from .style import Style DEFAULT_STYLES: Dict[str, Style] = { - "none": Style(), + "none": Style("none"), "reset": Style.reset(), - "dim": Style(dim=True), - "bright": Style(dim=False), - "bold": Style(bold=True), - "b": Style(bold=True), - "italic": Style(italic=True), - "i": Style(italic=True), - "underline": Style(underline=True), - "u": Style(underline=True), - "blink": Style(blink=True), - "blink2": Style(blink2=True), - "reverse": Style(reverse=True), - "strike": Style(strike=True), - "s": Style(strike=True), - "black": Style(color="black"), - "red": Style(color="red"), - "green": Style(color="green"), - "yellow": Style(color="yellow"), - "magenta": Style(color="magenta"), - "cyan": Style(color="cyan"), - "white": Style(color="white"), - "on_black": Style(back="black"), - "on_red": Style(back="red"), - "on_green": Style(back="green"), - "on_yellow": Style(back="yellow"), - "on_magenta": Style(back="magenta"), - "on_cyan": Style(back="cyan"), - "on_white": Style(back="white"), + "dim": Style("dim", dim=True), + "bright": Style("bright", dim=False), + "bold": Style("bold", bold=True), + "b": Style("bold", bold=True), + "italic": Style("italic", italic=True), + "i": Style("italic", italic=True), + "underline": Style("underline", underline=True), + "u": Style("underline", underline=True), + "blink": Style("blink", blink=True), + "blink2": Style("blink2", blink2=True), + "reverse": Style("reverse", reverse=True), + "strike": Style("strike", strike=True), + "s": Style("strike", strike=True), + "black": Style("black", color="black"), + "red": Style("red", color="red"), + "green": Style("green", color="green"), + "yellow": Style("yellow", color="yellow"), + "magenta": Style("magenta", color="magenta"), + "cyan": Style("cyan", color="cyan"), + "white": Style("white", color="white"), + "on_black": Style("on_black", back="black"), + "on_red": Style("on_red", back="red"), + "on_green": Style("on_green", back="green"), + "on_yellow": Style("on_yellow", back="yellow"), + "on_magenta": Style("on_magenta", back="magenta"), + "on_cyan": Style("on_cyan", back="cyan"), + "on_white": Style("on_white", back="white"), } MARKDOWN_STYLES = { - "markdown.text": Style(), - "markdown.emph": Style(italic=True), - "markdown.strong": Style(bold=True), - "markdown.code": Style(dim=True), - "markdown.code_block": Style(dim=True), - "markdown.heading1": Style(bold=True), - "markdown.heading2": Style(bold=True, dim=True), - "markdown.heading3": Style(bold=True), - "markdown.heading4": Style(bold=True), - "markdown.heading5": Style(bold=True), - "markdown.heading6": Style(bold=True), - "markdown.heading7": Style(bold=True), + "markdown.paragraph": Style("markdown.paragraph"), + "markdown.text": Style("markdown.text"), + "markdown.emph": Style("markdown.emph", italic=True), + "markdown.strong": Style("markdown.strong", bold=True), + "markdown.code": Style("markdown.code", dim=True), + "markdown.code_block": Style( + "markdown.code_block", dim=True, color="cyan", back="black" + ), + "markdown.hr": Style("markdown.hr"), + "markdown.h1": Style("markdown.h1", bold=True, underline=True), + "markdown.h2": Style("markdown.h2", bold=True), + "markdown.h3": Style("markdown.h3", bold=True, dim=True), + "markdown.h4": Style("markdown.h4", bold=True, italic=True), + "markdown.h5": Style("markdown.h5", bold=True), + "markdown.h6": Style("markdown.h6", bold=True), + "markdown.h7": Style("markdown.h7", bold=True), } DEFAULT_STYLES.update(MARKDOWN_STYLES) diff --git a/rich/markdown.py b/rich/markdown.py index 52b41d37..1e812eca 100644 --- a/rich/markdown.py +++ b/rich/markdown.py @@ -3,20 +3,16 @@ from typing import Dict, Iterable, List, Optional from commonmark.blocks import Parser -from .console import Console, ConsoleOptions, StyledText +from .console import Console, ConsoleOptions, RenderResult, StyledText from .style import Style +from .text import Text +from ._stack import Stack -@dataclass -class MarkdownHeading: - """A Markdown document heading.""" - - text: str - level: int - width: int - - def __console__(self) -> str: - pass +class Heading(Text): + def __init__(self, level: int) -> None: + super().__init__() + self.level = level class Markdown: @@ -25,58 +21,119 @@ class Markdown: def __init__(self, markup): self.markup = markup - def __console_render__( - self, console: Console, options: ConsoleOptions - ) -> Iterable[StyledText]: + def __console__(self, console: Console, options: ConsoleOptions) -> RenderResult: - width = options.max_width parser = Parser() nodes = parser.parse(self.markup).walker() rendered: List[StyledText] = [] - append = rendered.append - stack = [Style()] + style_stack: Stack[Style] = Stack() + stack: Stack[Text] = Stack() + style_stack.push(Style()) - style: Optional[Style] + null_style = Style() + + def push_style(name: str) -> Style: + + style = console.get_style(name) or null_style + style = style_stack.top.apply(style) + style_stack.push(style) + return style + + def pop_style() -> Style: + return style_stack.pop() + + paragraph_count = 0 for current, entering in nodes: + node_type = current.t if node_type == "text": - style = stack[-1].apply(console.get_style("markdown.text")) - append(StyledText(current.literal, style)) + style = push_style("markdown.text") + stack.top.append(current.literal, style) + pop_style() elif node_type == "paragraph": - if not entering: - append(StyledText("\n\n", stack[-1])) + if entering: + if paragraph_count: + yield StyledText("\n\n") + paragraph_count += 1 + + push_style("markdown.paragraph") + stack.push(Text()) + else: + pop_style() + text = stack.pop() + yield text.wrap(options.max_width) + # yield StyledText("\n") + + # yield StyledText("\n") + elif node_type == "heading": + if entering: + push_style(f"markdown.h{current.level}") + stack.push(Heading(current.level)) + else: + pop_style() + text = stack.pop() + yield text.wrap(options.max_width, justify="center") + yield StyledText("\n\n") + elif node_type == "code_block": + style = push_style("markdown.code_block") + text = Text(current.literal.rstrip(), style=style) + wrapped_text = text.wrap(options.max_width, justify="left") + yield StyledText("\n\n") + yield wrapped_text + pop_style() + + elif node_type == "code": + style = push_style("markdown.code") + stack.top.append(current.literal, style) + pop_style() + elif node_type == "softbreak": + stack.top.append("\n") + elif node_type == "thematic_break": + style = push_style("markdown.hr") + yield StyledText(f"\n{'—' * options.max_width}\n", style) + paragraph_count = 0 + pop_style() else: if entering: - style = console.get_style(f"markdown.{node_type}") - if style is not None: - stack.append(stack[-1].apply(style)) - else: - stack.append(stack[-1]) - if current.literal: - append(StyledText(current.literal, stack[-1])) + push_style(f"markdown.{node_type}") else: - stack.pop() + pop_style() - print(rendered) - return rendered + yield from rendered -markup = """*hello*, **world**! +markup = """ +# This is a header -# Hi +The main area where I think *Django's models* are `missing` out is the lack of type hinting (hardly surprising since **Django** pre-dates type hints). Adding type hints allows Mypy to detect bugs before you even run your code. It may only save you minutes each time, but multiply that by the number of code + run iterations you do each day, and it can save hours of development time. Multiply that by the lifetime of your project, and it could save weeks or months. A clear win. -```python -code +``` + @property + def width(self) -> int: + \"\"\"Get the width of the console. + + Returns: + int: The width (in characters) of the console. + \"\"\" + width, _ = self.size + return width ``` +The main area where I think Django's models are missing out is the lack of type hinting (hardly surprising since Django pre-dates type hints). Adding type hints allows Mypy to detect bugs before you even run your code. It may only save you minutes each time, but multiply that by the number of code + run iterations you do each day, and it can save hours of development time. Multiply that by the lifetime of your project, and it could save weeks or months. A clear win. + +--- + +> This is a *block* quote +> With another line """ if __name__ == "__main__": from .console import Console - console = Console() + console = Console(width=79) + print(console.size) md = Markdown(markup) console.print(md) diff --git a/rich/style.py b/rich/style.py index 72e4f630..892f8695 100644 --- a/rich/style.py +++ b/rich/style.py @@ -13,6 +13,7 @@ from .color import Color class Style: """A terminal style.""" + name: Optional[str] = None color: Optional[str] = None back: Optional[str] = None bold: Optional[bool] = None @@ -55,7 +56,11 @@ class Style: return " ".join(attributes) or "none" def __repr__(self): - return f"