Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/rich/markdown.py: 94%
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1from __future__ import annotations
3import sys
4from dataclasses import dataclass
5from typing import ClassVar, Iterable, get_args
7from markdown_it import MarkdownIt
8from markdown_it.token import Token
10from rich.table import Table
12from . import box
13from ._loop import loop_first
14from ._stack import Stack
15from .console import Console, ConsoleOptions, JustifyMethod, RenderResult
16from .containers import Renderables
17from .jupyter import JupyterMixin
18from .rule import Rule
19from .segment import Segment
20from .style import Style, StyleStack
21from .syntax import Syntax
22from .text import Text, TextType
25class MarkdownElement:
26 new_line: ClassVar[bool] = True
28 @classmethod
29 def create(cls, markdown: Markdown, token: Token) -> MarkdownElement:
30 """Factory to create markdown element,
32 Args:
33 markdown (Markdown): The parent Markdown object.
34 token (Token): A node from markdown-it.
36 Returns:
37 MarkdownElement: A new markdown element
38 """
39 return cls()
41 def on_enter(self, context: MarkdownContext) -> None:
42 """Called when the node is entered.
44 Args:
45 context (MarkdownContext): The markdown context.
46 """
48 def on_text(self, context: MarkdownContext, text: TextType) -> None:
49 """Called when text is parsed.
51 Args:
52 context (MarkdownContext): The markdown context.
53 """
55 def on_leave(self, context: MarkdownContext) -> None:
56 """Called when the parser leaves the element.
58 Args:
59 context (MarkdownContext): [description]
60 """
62 def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:
63 """Called when a child element is closed.
65 This method allows a parent element to take over rendering of its children.
67 Args:
68 context (MarkdownContext): The markdown context.
69 child (MarkdownElement): The child markdown element.
71 Returns:
72 bool: Return True to render the element, or False to not render the element.
73 """
74 return True
76 def __rich_console__(
77 self, console: Console, options: ConsoleOptions
78 ) -> RenderResult:
79 return ()
82class UnknownElement(MarkdownElement):
83 """An unknown element.
85 Hopefully there will be no unknown elements, and we will have a MarkdownElement for
86 everything in the document.
88 """
91class TextElement(MarkdownElement):
92 """Base class for elements that render text."""
94 style_name = "none"
96 def on_enter(self, context: MarkdownContext) -> None:
97 self.style = context.enter_style(self.style_name)
98 self.text = Text(justify="left")
100 def on_text(self, context: MarkdownContext, text: TextType) -> None:
101 self.text.append(text, context.current_style if isinstance(text, str) else None)
103 def on_leave(self, context: MarkdownContext) -> None:
104 context.leave_style()
107class Paragraph(TextElement):
108 """A Paragraph."""
110 style_name = "markdown.paragraph"
111 justify: JustifyMethod
113 @classmethod
114 def create(cls, markdown: Markdown, token: Token) -> Paragraph:
115 return cls(justify=markdown.justify or "left")
117 def __init__(self, justify: JustifyMethod) -> None:
118 self.justify = justify
120 def __rich_console__(
121 self, console: Console, options: ConsoleOptions
122 ) -> RenderResult:
123 self.text.justify = self.justify
124 yield self.text
127@dataclass
128class HeadingFormat:
129 justify: JustifyMethod = "left"
130 style: str = ""
133class Heading(TextElement):
134 """A heading."""
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 }
145 @classmethod
146 def create(cls, markdown: Markdown, token: Token) -> Heading:
147 return cls(token.tag)
149 def on_enter(self, context: MarkdownContext) -> None:
150 self.text = Text()
151 context.enter_style(self.style_name)
153 def __init__(self, tag: str) -> None:
154 self.tag = tag
155 self.style_name = f"markdown.{tag}"
156 super().__init__()
158 def __rich_console__(
159 self, console: Console, options: ConsoleOptions
160 ) -> RenderResult:
161 text = self.text.copy()
162 heading_justify = self.LEVEL_ALIGN.get(self.tag, "left")
163 text.justify = heading_justify
164 yield text
167class CodeBlock(TextElement):
168 """A code block with syntax highlighting."""
170 style_name = "markdown.code_block"
172 @classmethod
173 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)
178 def __init__(self, lexer_name: str, theme: str) -> None:
179 self.lexer_name = lexer_name
180 self.theme = theme
182 def __rich_console__(
183 self, console: Console, options: ConsoleOptions
184 ) -> RenderResult:
185 code = str(self.text).rstrip()
186 syntax = Syntax(
187 code, self.lexer_name, theme=self.theme, word_wrap=True, padding=1
188 )
189 yield syntax
192class BlockQuote(TextElement):
193 """A block quote."""
195 style_name = "markdown.block_quote"
197 def __init__(self) -> None:
198 self.elements: Renderables = Renderables()
200 def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:
201 self.elements.append(child)
202 return False
204 def __rich_console__(
205 self, console: Console, options: ConsoleOptions
206 ) -> 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.style
210 new_line = Segment("\n")
211 padding = Segment("▌ ", style)
212 for line in lines:
213 yield padding
214 yield from line
215 yield new_line
218class HorizontalRule(MarkdownElement):
219 """A horizontal rule to divide sections."""
221 new_line = False
223 def __rich_console__(
224 self, console: Console, options: ConsoleOptions
225 ) -> RenderResult:
226 style = console.get_style("markdown.hr", default="none")
227 yield Rule(style=style, characters="-")
228 yield Text()
231class TableElement(MarkdownElement):
232 """MarkdownElement corresponding to `table_open`."""
234 def __init__(self) -> None:
235 self.header: TableHeaderElement | None = None
236 self.body: TableBodyElement | None = None
238 def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:
239 if isinstance(child, TableHeaderElement):
240 self.header = child
241 elif isinstance(child, TableBodyElement):
242 self.body = child
243 else:
244 raise RuntimeError("Couldn't process markdown table.")
245 return False
247 def __rich_console__(
248 self, console: Console, options: ConsoleOptions
249 ) -> 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 )
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)
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)
269 yield table
272class TableHeaderElement(MarkdownElement):
273 """MarkdownElement corresponding to `thead_open` and `thead_close`."""
275 def __init__(self) -> None:
276 self.row: TableRowElement | None = None
278 def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:
279 assert isinstance(child, TableRowElement)
280 self.row = child
281 return False
284class TableBodyElement(MarkdownElement):
285 """MarkdownElement corresponding to `tbody_open` and `tbody_close`."""
287 def __init__(self) -> None:
288 self.rows: list[TableRowElement] = []
290 def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:
291 assert isinstance(child, TableRowElement)
292 self.rows.append(child)
293 return False
296class TableRowElement(MarkdownElement):
297 """MarkdownElement corresponding to `tr_open` and `tr_close`."""
299 def __init__(self) -> None:
300 self.cells: list[TableDataElement] = []
302 def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:
303 assert isinstance(child, TableDataElement)
304 self.cells.append(child)
305 return False
308class TableDataElement(MarkdownElement):
309 """MarkdownElement corresponding to `td_open` and `td_close`
310 and `th_open` and `th_close`."""
312 @classmethod
313 def create(cls, markdown: Markdown, token: Token) -> MarkdownElement:
314 style = str(token.attrs.get("style")) or ""
316 justify: JustifyMethod
317 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"
326 assert justify in get_args(JustifyMethod)
327 return cls(justify=justify)
329 def __init__(self, justify: JustifyMethod) -> None:
330 self.content: Text = Text("", justify=justify)
331 self.justify = justify
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)
340class ListElement(MarkdownElement):
341 """A list element."""
343 @classmethod
344 def create(cls, markdown: Markdown, token: Token) -> ListElement:
345 return cls(token.type, int(token.attrs.get("start", 1)))
347 def __init__(self, list_type: str, list_start: int | None) -> None:
348 self.items: list[ListItem] = []
349 self.list_type = list_type
350 self.list_start = list_start
352 def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:
353 assert isinstance(child, ListItem)
354 self.items.append(child)
355 return False
357 def __rich_console__(
358 self, console: Console, options: ConsoleOptions
359 ) -> 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_start
365 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_number
369 )
372class ListItem(TextElement):
373 """An item in a list."""
375 style_name = "markdown.item"
377 def __init__(self) -> None:
378 self.elements: Renderables = Renderables()
380 def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:
381 self.elements.append(child)
382 return False
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")
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 padding
394 yield from line
395 yield new_line
397 def render_number(
398 self, console: Console, options: ConsoleOptions, number: int, last_number: int
399 ) -> RenderResult:
400 number_width = len(str(last_number)) + 2
401 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")
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 padding
410 yield from line
411 yield new_line
414class Link(TextElement):
415 @classmethod
416 def create(cls, markdown: Markdown, token: Token) -> MarkdownElement:
417 url = token.attrs.get("href", "#")
418 return cls(token.content, str(url))
420 def __init__(self, text: str, href: str):
421 self.text = Text(text)
422 self.href = href
425class ImageItem(TextElement):
426 """Renders a placeholder for an image."""
428 new_line = False
430 @classmethod
431 def create(cls, markdown: Markdown, token: Token) -> MarkdownElement:
432 """Factory to create markdown element,
434 Args:
435 markdown (Markdown): The parent Markdown object.
436 token (Any): A token from markdown-it.
438 Returns:
439 MarkdownElement: A new markdown element
440 """
441 return cls(str(token.attrs.get("src", "")), markdown.hyperlinks)
443 def __init__(self, destination: str, hyperlinks: bool) -> None:
444 self.destination = destination
445 self.hyperlinks = hyperlinks
446 self.link: str | None = None
447 super().__init__()
449 def on_enter(self, context: MarkdownContext) -> None:
450 self.link = context.current_style.link
451 self.text = Text(justify="left")
452 super().on_enter(context)
454 def __rich_console__(
455 self, console: Console, options: ConsoleOptions
456 ) -> 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 text
465class MarkdownContext:
466 """Manages the console render state."""
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 = console
477 self.options = options
478 self.style_stack: StyleStack = StyleStack(style)
479 self.stack: Stack[MarkdownElement] = Stack()
481 self._syntax: Syntax | None = None
482 if inline_code_lexer is not None:
483 self._syntax = Syntax("", inline_code_lexer, theme=inline_code_theme)
485 @property
486 def current_style(self) -> Style:
487 """Current style which is the product of all styles on the stack."""
488 return self.style_stack.current
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)
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_style
507 def leave_style(self) -> Style:
508 """Leave a style context."""
509 style = self.style_stack.pop()
510 return style
513class Markdown(JupyterMixin):
514 """A Markdown renderable.
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 is
523 enabled. Defaults to None.
524 inline_code_theme: (Optional[str], optional): Pygments theme for inline code
525 highlighting, or None for no highlighting. Defaults to None.
526 """
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 }
547 inlines = {"em", "strong", "code", "s"}
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 = markup
561 self.parsed = parser.parse(markup)
562 self.code_theme = code_theme
563 self.justify: JustifyMethod | None = justify
564 self.style = style
565 self.hyperlinks = hyperlinks
566 self.inline_code_lexer = inline_code_lexer
567 self.inline_code_theme = inline_code_theme or code_theme
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 token
579 def __rich_console__(
580 self, console: Console, options: ConsoleOptions
581 ) -> 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.parsed
593 inline_style_tags = self.inlines
594 new_line = False
595 _new_line_segment = Segment.line()
597 for token in self._flatten_tokens(tokens):
598 node_type = token.type
599 tag = token.tag
601 entering = token.nesting == 1
602 exiting = token.nesting == -1
603 self_closing = token.nesting == 0
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 continue
627 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_tags
647 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 style
656 # 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 renderable
666 element_class = self.elements.get(token.type) or UnknownElement
667 element = element_class.create(self, token)
669 if entering or self_closing:
670 context.stack.push(element)
671 element.on_enter(context)
673 if exiting: # CLOSING tag
674 element = context.stack.pop()
676 should_render = not context.stack or (
677 context.stack
678 and context.stack.top.on_child_close(context, element)
679 )
681 if should_render:
682 if new_line:
683 yield _new_line_segment
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.content
689 if text is not None:
690 element.on_text(context, text)
692 should_render = (
693 not context.stack
694 or context.stack
695 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_segment
700 yield from console.render(element, context.options)
702 if exiting or self_closing:
703 element.on_leave(context)
704 new_line = element.new_line
707if __name__ == "__main__": # pragma: no cover
708 import argparse
709 import sys
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()
772 from rich.console import Console
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()
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 io
789 import pydoc
791 fileio = io.StringIO()
792 console = Console(
793 file=fileio, force_terminal=args.force_color, width=args.width
794 )
795 console.print(markdown)
796 pydoc.pager(fileio.getvalue())
798 else:
799 console = Console(
800 force_terminal=args.force_color, width=args.width, record=True
801 )
802 console.print(markdown)