Team Ai
Datasetpublic

codekingpro/portable-devtools

sourceHugging Faceupdated 5mo agoView on Hugging Face
1likes14kdownloads
markdown.py803 linesDownload Raw Back to rich
1from __future__ import annotations2 3import sys4from dataclasses import dataclass5from typing import ClassVar, Iterable, get_args6 7from markdown_it import MarkdownIt8from markdown_it.token import Token9 10from rich.table import Table11 12from . import box13from ._loop import loop_first14from ._stack import Stack15from .console import Console, ConsoleOptions, JustifyMethod, RenderResult16from .containers import Renderables17from .jupyter import JupyterMixin18from .rule import Rule19from .segment import Segment20from .style import Style, StyleStack21from .syntax import Syntax22from .text import Text, TextType23 24 25class MarkdownElement:26    new_line: ClassVar[bool] = True27 28    @classmethod29    def create(cls, markdown: Markdown, token: Token) -> MarkdownElement:30        """Factory to create markdown element,31 32        Args:33            markdown (Markdown): The parent Markdown object.34            token (Token): A node from markdown-it.35 36        Returns:37            MarkdownElement: A new markdown element38        """39        return cls()40 41    def on_enter(self, context: MarkdownContext) -> None:42        """Called when the node is entered.43 44        Args:45            context (MarkdownContext): The markdown context.46        """47 48    def on_text(self, context: MarkdownContext, text: TextType) -> None:49        """Called when text is parsed.50 51        Args:52            context (MarkdownContext): The markdown context.53        """54 55    def on_leave(self, context: MarkdownContext) -> None:56        """Called when the parser leaves the element.57 58        Args:59            context (MarkdownContext): [description]60        """61 62    def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:63        """Called when a child element is closed.64 65        This method allows a parent element to take over rendering of its children.66 67        Args:68            context (MarkdownContext): The markdown context.69            child (MarkdownElement): The child markdown element.70 71        Returns:72            bool: Return True to render the element, or False to not render the element.73        """74        return True75 76    def __rich_console__(77        self, console: Console, options: ConsoleOptions78    ) -> RenderResult:79        return ()80 81 82class UnknownElement(MarkdownElement):83    """An unknown element.84 85    Hopefully there will be no unknown elements, and we will have a MarkdownElement for86    everything in the document.87 88    """89 90 91class TextElement(MarkdownElement):92    """Base class for elements that render text."""93 94    style_name = "none"95 96    def on_enter(self, context: MarkdownContext) -> None:97        self.style = context.enter_style(self.style_name)98        self.text = Text(justify="left")99 100    def on_text(self, context: MarkdownContext, text: TextType) -> None:101        self.text.append(text, context.current_style if isinstance(text, str) else None)102 103    def on_leave(self, context: MarkdownContext) -> None:104        context.leave_style()105 106 107class Paragraph(TextElement):108    """A Paragraph."""109 110    style_name = "markdown.paragraph"111    justify: JustifyMethod112 113    @classmethod114    def create(cls, markdown: Markdown, token: Token) -> Paragraph:115        return cls(justify=markdown.justify or "left")116 117    def __init__(self, justify: JustifyMethod) -> None:118        self.justify = justify119 120    def __rich_console__(121        self, console: Console, options: ConsoleOptions122    ) -> RenderResult:123        self.text.justify = self.justify124        yield self.text125 126 127@dataclass128class HeadingFormat:129    justify: JustifyMethod = "left"130    style: str = ""131 132 133class Heading(TextElement):134    """A heading."""135 136    LEVEL_ALIGN: ClassVar[dict[str, JustifyMethod]] = {137        "h1": "center",138        "h2": "left",139        "h3": "left",140        "h4": "left",141        "h5": "left",142        "h6": "left",143    }144 145    @classmethod146    def create(cls, markdown: Markdown, token: Token) -> Heading:147        return cls(token.tag)148 149    def on_enter(self, context: MarkdownContext) -> None:150        self.text = Text()151        context.enter_style(self.style_name)152 153    def __init__(self, tag: str) -> None:154        self.tag = tag155        self.style_name = f"markdown.{tag}"156        super().__init__()157 158    def __rich_console__(159        self, console: Console, options: ConsoleOptions160    ) -> RenderResult:161        text = self.text.copy()162        heading_justify = self.LEVEL_ALIGN.get(self.tag, "left")163        text.justify = heading_justify164        yield text165 166 167class CodeBlock(TextElement):168    """A code block with syntax highlighting."""169 170    style_name = "markdown.code_block"171 172    @classmethod173    def create(cls, markdown: Markdown, token: Token) -> CodeBlock:174        node_info = token.info or ""175        lexer_name = node_info.partition(" ")[0]176        return cls(lexer_name or "text", markdown.code_theme)177 178    def __init__(self, lexer_name: str, theme: str) -> None:179        self.lexer_name = lexer_name180        self.theme = theme181 182    def __rich_console__(183        self, console: Console, options: ConsoleOptions184    ) -> RenderResult:185        code = str(self.text).rstrip()186        syntax = Syntax(187            code, self.lexer_name, theme=self.theme, word_wrap=True, padding=1188        )189        yield syntax190 191 192class BlockQuote(TextElement):193    """A block quote."""194 195    style_name = "markdown.block_quote"196 197    def __init__(self) -> None:198        self.elements: Renderables = Renderables()199 200    def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:201        self.elements.append(child)202        return False203 204    def __rich_console__(205        self, console: Console, options: ConsoleOptions206    ) -> RenderResult:207        render_options = options.update(width=options.max_width - 4)208        lines = console.render_lines(self.elements, render_options, style=self.style)209        style = self.style210        new_line = Segment("\n")211        padding = Segment("▌ ", style)212        for line in lines:213            yield padding214            yield from line215            yield new_line216 217 218class HorizontalRule(MarkdownElement):219    """A horizontal rule to divide sections."""220 221    new_line = False222 223    def __rich_console__(224        self, console: Console, options: ConsoleOptions225    ) -> RenderResult:226        style = console.get_style("markdown.hr", default="none")227        yield Rule(style=style, characters="-")228        yield Text()229 230 231class TableElement(MarkdownElement):232    """MarkdownElement corresponding to `table_open`."""233 234    def __init__(self) -> None:235        self.header: TableHeaderElement | None = None236        self.body: TableBodyElement | None = None237 238    def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:239        if isinstance(child, TableHeaderElement):240            self.header = child241        elif isinstance(child, TableBodyElement):242            self.body = child243        else:244            raise RuntimeError("Couldn't process markdown table.")245        return False246 247    def __rich_console__(248        self, console: Console, options: ConsoleOptions249    ) -> RenderResult:250        table = Table(251            box=box.SIMPLE,252            pad_edge=False,253            style="markdown.table.border",254            show_edge=True,255            collapse_padding=True,256        )257 258        if self.header is not None and self.header.row is not None:259            for column in self.header.row.cells:260                heading = column.content.copy()261                heading.stylize("markdown.table.header")262                table.add_column(heading)263 264        if self.body is not None:265            for row in self.body.rows:266                row_content = [element.content for element in row.cells]267                table.add_row(*row_content)268 269        yield table270 271 272class TableHeaderElement(MarkdownElement):273    """MarkdownElement corresponding to `thead_open` and `thead_close`."""274 275    def __init__(self) -> None:276        self.row: TableRowElement | None = None277 278    def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:279        assert isinstance(child, TableRowElement)280        self.row = child281        return False282 283 284class TableBodyElement(MarkdownElement):285    """MarkdownElement corresponding to `tbody_open` and `tbody_close`."""286 287    def __init__(self) -> None:288        self.rows: list[TableRowElement] = []289 290    def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:291        assert isinstance(child, TableRowElement)292        self.rows.append(child)293        return False294 295 296class TableRowElement(MarkdownElement):297    """MarkdownElement corresponding to `tr_open` and `tr_close`."""298 299    def __init__(self) -> None:300        self.cells: list[TableDataElement] = []301 302    def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:303        assert isinstance(child, TableDataElement)304        self.cells.append(child)305        return False306 307 308class TableDataElement(MarkdownElement):309    """MarkdownElement corresponding to `td_open` and `td_close`310    and `th_open` and `th_close`."""311 312    @classmethod313    def create(cls, markdown: Markdown, token: Token) -> MarkdownElement:314        style = str(token.attrs.get("style")) or ""315 316        justify: JustifyMethod317        if "text-align:right" in style:318            justify = "right"319        elif "text-align:center" in style:320            justify = "center"321        elif "text-align:left" in style:322            justify = "left"323        else:324            justify = "default"325 326        assert justify in get_args(JustifyMethod)327        return cls(justify=justify)328 329    def __init__(self, justify: JustifyMethod) -> None:330        self.content: Text = Text("", justify=justify)331        self.justify = justify332 333    def on_text(self, context: MarkdownContext, text: TextType) -> None:334        if isinstance(text, str):335            self.content.append(text, context.current_style)336        else:337            self.content.append_text(text)338 339 340class ListElement(MarkdownElement):341    """A list element."""342 343    @classmethod344    def create(cls, markdown: Markdown, token: Token) -> ListElement:345        return cls(token.type, int(token.attrs.get("start", 1)))346 347    def __init__(self, list_type: str, list_start: int | None) -> None:348        self.items: list[ListItem] = []349        self.list_type = list_type350        self.list_start = list_start351 352    def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:353        assert isinstance(child, ListItem)354        self.items.append(child)355        return False356 357    def __rich_console__(358        self, console: Console, options: ConsoleOptions359    ) -> RenderResult:360        if self.list_type == "bullet_list_open":361            for item in self.items:362                yield from item.render_bullet(console, options)363        else:364            number = 1 if self.list_start is None else self.list_start365            last_number = number + len(self.items)366            for index, item in enumerate(self.items):367                yield from item.render_number(368                    console, options, number + index, last_number369                )370 371 372class ListItem(TextElement):373    """An item in a list."""374 375    style_name = "markdown.item"376 377    def __init__(self) -> None:378        self.elements: Renderables = Renderables()379 380    def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:381        self.elements.append(child)382        return False383 384    def render_bullet(self, console: Console, options: ConsoleOptions) -> RenderResult:385        render_options = options.update(width=options.max_width - 3)386        lines = console.render_lines(self.elements, render_options, style=self.style)387        bullet_style = console.get_style("markdown.item.bullet", default="none")388 389        bullet = Segment(" • ", bullet_style)390        padding = Segment(" " * 3, bullet_style)391        new_line = Segment("\n")392        for first, line in loop_first(lines):393            yield bullet if first else padding394            yield from line395            yield new_line396 397    def render_number(398        self, console: Console, options: ConsoleOptions, number: int, last_number: int399    ) -> RenderResult:400        number_width = len(str(last_number)) + 2401        render_options = options.update(width=options.max_width - number_width)402        lines = console.render_lines(self.elements, render_options, style=self.style)403        number_style = console.get_style("markdown.item.number", default="none")404 405        new_line = Segment("\n")406        padding = Segment(" " * number_width, number_style)407        numeral = Segment(f"{number}".rjust(number_width - 1) + " ", number_style)408        for first, line in loop_first(lines):409            yield numeral if first else padding410            yield from line411            yield new_line412 413 414class Link(TextElement):415    @classmethod416    def create(cls, markdown: Markdown, token: Token) -> MarkdownElement:417        url = token.attrs.get("href", "#")418        return cls(token.content, str(url))419 420    def __init__(self, text: str, href: str):421        self.text = Text(text)422        self.href = href423 424 425class ImageItem(TextElement):426    """Renders a placeholder for an image."""427 428    new_line = False429 430    @classmethod431    def create(cls, markdown: Markdown, token: Token) -> MarkdownElement:432        """Factory to create markdown element,433 434        Args:435            markdown (Markdown): The parent Markdown object.436            token (Any): A token from markdown-it.437 438        Returns:439            MarkdownElement: A new markdown element440        """441        return cls(str(token.attrs.get("src", "")), markdown.hyperlinks)442 443    def __init__(self, destination: str, hyperlinks: bool) -> None:444        self.destination = destination445        self.hyperlinks = hyperlinks446        self.link: str | None = None447        super().__init__()448 449    def on_enter(self, context: MarkdownContext) -> None:450        self.link = context.current_style.link451        self.text = Text(justify="left")452        super().on_enter(context)453 454    def __rich_console__(455        self, console: Console, options: ConsoleOptions456    ) -> RenderResult:457        link_style = Style(link=self.link or self.destination or None)458        title = self.text or Text(self.destination.strip("/").rsplit("/", 1)[-1])459        if self.hyperlinks:460            title.stylize(link_style)461        text = Text.assemble("🌆 ", title, " ", end="")462        yield text463 464 465class MarkdownContext:466    """Manages the console render state."""467 468    def __init__(469        self,470        console: Console,471        options: ConsoleOptions,472        style: Style,473        inline_code_lexer: str | None = None,474        inline_code_theme: str = "monokai",475    ) -> None:476        self.console = console477        self.options = options478        self.style_stack: StyleStack = StyleStack(style)479        self.stack: Stack[MarkdownElement] = Stack()480 481        self._syntax: Syntax | None = None482        if inline_code_lexer is not None:483            self._syntax = Syntax("", inline_code_lexer, theme=inline_code_theme)484 485    @property486    def current_style(self) -> Style:487        """Current style which is the product of all styles on the stack."""488        return self.style_stack.current489 490    def on_text(self, text: str, node_type: str) -> None:491        """Called when the parser visits text."""492        if node_type in {"fence", "code_inline"} and self._syntax is not None:493            highlight_text = self._syntax.highlight(text)494            highlight_text.rstrip()495            self.stack.top.on_text(496                self, Text.assemble(highlight_text, style=self.style_stack.current)497            )498        else:499            self.stack.top.on_text(self, text)500 501    def enter_style(self, style_name: str | Style) -> Style:502        """Enter a style context."""503        style = self.console.get_style(style_name, default="none")504        self.style_stack.push(style)505        return self.current_style506 507    def leave_style(self) -> Style:508        """Leave a style context."""509        style = self.style_stack.pop()510        return style511 512 513class Markdown(JupyterMixin):514    """A Markdown renderable.515 516    Args:517        markup (str): A string containing markdown.518        code_theme (str, optional): Pygments theme for code blocks. Defaults to "monokai". See https://pygments.org/styles/ for code themes.519        justify (JustifyMethod, optional): Justify value for paragraphs. Defaults to None.520        style (Union[str, Style], optional): Optional style to apply to markdown.521        hyperlinks (bool, optional): Enable hyperlinks. Defaults to ``True``.522        inline_code_lexer: (str, optional): Lexer to use if inline code highlighting is523            enabled. Defaults to None.524        inline_code_theme: (Optional[str], optional): Pygments theme for inline code525            highlighting, or None for no highlighting. Defaults to None.526    """527 528    elements: ClassVar[dict[str, type[MarkdownElement]]] = {529        "paragraph_open": Paragraph,530        "heading_open": Heading,531        "fence": CodeBlock,532        "code_block": CodeBlock,533        "blockquote_open": BlockQuote,534        "hr": HorizontalRule,535        "bullet_list_open": ListElement,536        "ordered_list_open": ListElement,537        "list_item_open": ListItem,538        "image": ImageItem,539        "table_open": TableElement,540        "tbody_open": TableBodyElement,541        "thead_open": TableHeaderElement,542        "tr_open": TableRowElement,543        "td_open": TableDataElement,544        "th_open": TableDataElement,545    }546 547    inlines = {"em", "strong", "code", "s"}548 549    def __init__(550        self,551        markup: str,552        code_theme: str = "monokai",553        justify: JustifyMethod | None = None,554        style: str | Style = "none",555        hyperlinks: bool = True,556        inline_code_lexer: str | None = None,557        inline_code_theme: str | None = None,558    ) -> None:559        parser = MarkdownIt().enable("strikethrough").enable("table")560        self.markup = markup561        self.parsed = parser.parse(markup)562        self.code_theme = code_theme563        self.justify: JustifyMethod | None = justify564        self.style = style565        self.hyperlinks = hyperlinks566        self.inline_code_lexer = inline_code_lexer567        self.inline_code_theme = inline_code_theme or code_theme568 569    def _flatten_tokens(self, tokens: Iterable[Token]) -> Iterable[Token]:570        """Flattens the token stream."""571        for token in tokens:572            is_fence = token.type == "fence"573            is_image = token.tag == "img"574            if token.children and not (is_image or is_fence):575                yield from self._flatten_tokens(token.children)576            else:577                yield token578 579    def __rich_console__(580        self, console: Console, options: ConsoleOptions581    ) -> RenderResult:582        """Render markdown to the console."""583        style = console.get_style(self.style, default="none")584        options = options.update(height=None)585        context = MarkdownContext(586            console,587            options,588            style,589            inline_code_lexer=self.inline_code_lexer,590            inline_code_theme=self.inline_code_theme,591        )592        tokens = self.parsed593        inline_style_tags = self.inlines594        new_line = False595        _new_line_segment = Segment.line()596 597        for token in self._flatten_tokens(tokens):598            node_type = token.type599            tag = token.tag600 601            entering = token.nesting == 1602            exiting = token.nesting == -1603            self_closing = token.nesting == 0604 605            if node_type == "text":606                context.on_text(token.content, node_type)607            elif node_type == "hardbreak":608                context.on_text("\n", node_type)609            elif node_type == "softbreak":610                context.on_text(" ", node_type)611            elif node_type == "link_open":612                href = str(token.attrs.get("href", ""))613                if self.hyperlinks:614                    link_style = console.get_style("markdown.link_url", default="none")615                    link_style += Style(link=href)616                    context.enter_style(link_style)617                else:618                    context.stack.push(Link.create(self, token))619            elif node_type == "html_inline":620                if token.content == "<kbd>":621                    kbd_style = console.get_style("markdown.kbd", default="bold")622                    context.enter_style(kbd_style)623                elif token.content == "</kbd>":624                    context.leave_style()625                else:626                    continue627            elif node_type == "link_close":628                if self.hyperlinks:629                    context.leave_style()630                else:631                    element = context.stack.pop()632                    assert isinstance(element, Link)633                    link_style = console.get_style("markdown.link", default="none")634                    context.enter_style(link_style)635                    context.on_text(element.text.plain, node_type)636                    context.leave_style()637                    context.on_text(" (", node_type)638                    link_url_style = console.get_style(639                        "markdown.link_url", default="none"640                    )641                    context.enter_style(link_url_style)642                    context.on_text(element.href, node_type)643                    context.leave_style()644                    context.on_text(")", node_type)645            elif (646                tag in inline_style_tags647                and node_type != "fence"648                and node_type != "code_block"649            ):650                if entering:651                    # If it's an opening inline token e.g. strong, em, etc.652                    # Then we move into a style context i.e. push to stack.653                    context.enter_style(f"markdown.{tag}")654                elif exiting:655                    # If it's a closing inline style, then we pop the style656                    # off of the stack, to move out of the context of it...657                    context.leave_style()658                else:659                    # If it's a self-closing inline style e.g. `code_inline`660                    context.enter_style(f"markdown.{tag}")661                    if token.content:662                        context.on_text(token.content, node_type)663                    context.leave_style()664            else:665                # Map the markdown tag -> MarkdownElement renderable666                element_class = self.elements.get(token.type) or UnknownElement667                element = element_class.create(self, token)668 669                if entering or self_closing:670                    context.stack.push(element)671                    element.on_enter(context)672 673                if exiting:  # CLOSING tag674                    element = context.stack.pop()675 676                    should_render = not context.stack or (677                        context.stack678                        and context.stack.top.on_child_close(context, element)679                    )680 681                    if should_render:682                        if new_line:683                            yield _new_line_segment684 685                        yield from console.render(element, context.options)686                elif self_closing:  # SELF-CLOSING tags (e.g. text, code, image)687                    context.stack.pop()688                    text = token.content689                    if text is not None:690                        element.on_text(context, text)691 692                    should_render = (693                        not context.stack694                        or context.stack695                        and context.stack.top.on_child_close(context, element)696                    )697                    if should_render:698                        if new_line and node_type != "inline":699                            yield _new_line_segment700                        yield from console.render(element, context.options)701 702                if exiting or self_closing:703                    element.on_leave(context)704                    new_line = element.new_line705 706 707if __name__ == "__main__":  # pragma: no cover708    import argparse709    import sys710 711    parser = argparse.ArgumentParser(712        description="Render Markdown to the console with Rich"713    )714    parser.add_argument(715        "path",716        metavar="PATH",717        help="path to markdown file, or - for stdin",718    )719    parser.add_argument(720        "-c",721        "--force-color",722        dest="force_color",723        action="store_true",724        default=None,725        help="force color for non-terminals",726    )727    parser.add_argument(728        "-t",729        "--code-theme",730        dest="code_theme",731        default="monokai",732        help="pygments code theme",733    )734    parser.add_argument(735        "-i",736        "--inline-code-lexer",737        dest="inline_code_lexer",738        default=None,739        help="inline_code_lexer",740    )741    parser.add_argument(742        "-y",743        "--hyperlinks",744        dest="hyperlinks",745        action="store_true",746        help="enable hyperlinks",747    )748    parser.add_argument(749        "-w",750        "--width",751        type=int,752        dest="width",753        default=None,754        help="width of output (default will auto-detect)",755    )756    parser.add_argument(757        "-j",758        "--justify",759        dest="justify",760        action="store_true",761        help="enable full text justify",762    )763    parser.add_argument(764        "-p",765        "--page",766        dest="page",767        action="store_true",768        help="use pager to scroll output",769    )770    args = parser.parse_args()771 772    from rich.console import Console773 774    if args.path == "-":775        markdown_body = sys.stdin.read()776    else:777        with open(args.path, encoding="utf-8") as markdown_file:778            markdown_body = markdown_file.read()779 780    markdown = Markdown(781        markdown_body,782        justify="full" if args.justify else "left",783        code_theme=args.code_theme,784        hyperlinks=args.hyperlinks,785        inline_code_lexer=args.inline_code_lexer,786    )787    if args.page:788        import io789        import pydoc790 791        fileio = io.StringIO()792        console = Console(793            file=fileio, force_terminal=args.force_color, width=args.width794        )795        console.print(markdown)796        pydoc.pager(fileio.getvalue())797 798    else:799        console = Console(800            force_terminal=args.force_color, width=args.width, record=True801        )802        console.print(markdown)803 
codekingpro/portable-devtools · Team Ai