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 typing import ClassVar, Iterable, get_args
6from markdown_it import MarkdownIt
7from markdown_it.token import Token
9from rich.table import Table
11from . import box
12from ._loop import loop_first
13from ._stack import Stack
14from .console import Console, ConsoleOptions, JustifyMethod, RenderResult
15from .containers import Renderables
16from .jupyter import JupyterMixin
17from .panel import Panel
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
127class Heading(TextElement):
128 """A heading."""
130 @classmethod
131 def create(cls, markdown: Markdown, token: Token) -> Heading:
132 return cls(token.tag)
134 def on_enter(self, context: MarkdownContext) -> None:
135 self.text = Text()
136 context.enter_style(self.style_name)
138 def __init__(self, tag: str) -> None:
139 self.tag = tag
140 self.style_name = f"markdown.{tag}"
141 super().__init__()
143 def __rich_console__(
144 self, console: Console, options: ConsoleOptions
145 ) -> RenderResult:
146 text = self.text
147 text.justify = "center"
148 if self.tag == "h1":
149 # Draw a border around h1s
150 yield Panel(
151 text,
152 box=box.HEAVY,
153 style="markdown.h1.border",
154 )
155 else:
156 # Styled text for h2 and beyond
157 if self.tag == "h2":
158 yield Text("")
159 yield text
162class CodeBlock(TextElement):
163 """A code block with syntax highlighting."""
165 style_name = "markdown.code_block"
167 @classmethod
168 def create(cls, markdown: Markdown, token: Token) -> CodeBlock:
169 node_info = token.info or ""
170 lexer_name = node_info.partition(" ")[0]
171 return cls(lexer_name or "text", markdown.code_theme)
173 def __init__(self, lexer_name: str, theme: str) -> None:
174 self.lexer_name = lexer_name
175 self.theme = theme
177 def __rich_console__(
178 self, console: Console, options: ConsoleOptions
179 ) -> RenderResult:
180 code = str(self.text).rstrip()
181 syntax = Syntax(
182 code, self.lexer_name, theme=self.theme, word_wrap=True, padding=1
183 )
184 yield syntax
187class BlockQuote(TextElement):
188 """A block quote."""
190 style_name = "markdown.block_quote"
192 def __init__(self) -> None:
193 self.elements: Renderables = Renderables()
195 def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:
196 self.elements.append(child)
197 return False
199 def __rich_console__(
200 self, console: Console, options: ConsoleOptions
201 ) -> RenderResult:
202 render_options = options.update(width=options.max_width - 4)
203 lines = console.render_lines(self.elements, render_options, style=self.style)
204 style = self.style
205 new_line = Segment("\n")
206 padding = Segment("▌ ", style)
207 for line in lines:
208 yield padding
209 yield from line
210 yield new_line
213class HorizontalRule(MarkdownElement):
214 """A horizontal rule to divide sections."""
216 new_line = False
218 def __rich_console__(
219 self, console: Console, options: ConsoleOptions
220 ) -> RenderResult:
221 style = console.get_style("markdown.hr", default="none")
222 yield Rule(style=style)
225class TableElement(MarkdownElement):
226 """MarkdownElement corresponding to `table_open`."""
228 def __init__(self) -> None:
229 self.header: TableHeaderElement | None = None
230 self.body: TableBodyElement | None = None
232 def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:
233 if isinstance(child, TableHeaderElement):
234 self.header = child
235 elif isinstance(child, TableBodyElement):
236 self.body = child
237 else:
238 raise RuntimeError("Couldn't process markdown table.")
239 return False
241 def __rich_console__(
242 self, console: Console, options: ConsoleOptions
243 ) -> RenderResult:
244 table = Table(box=box.SIMPLE_HEAVY)
246 if self.header is not None and self.header.row is not None:
247 for column in self.header.row.cells:
248 table.add_column(column.content)
250 if self.body is not None:
251 for row in self.body.rows:
252 row_content = [element.content for element in row.cells]
253 table.add_row(*row_content)
255 yield table
258class TableHeaderElement(MarkdownElement):
259 """MarkdownElement corresponding to `thead_open` and `thead_close`."""
261 def __init__(self) -> None:
262 self.row: TableRowElement | None = None
264 def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:
265 assert isinstance(child, TableRowElement)
266 self.row = child
267 return False
270class TableBodyElement(MarkdownElement):
271 """MarkdownElement corresponding to `tbody_open` and `tbody_close`."""
273 def __init__(self) -> None:
274 self.rows: list[TableRowElement] = []
276 def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:
277 assert isinstance(child, TableRowElement)
278 self.rows.append(child)
279 return False
282class TableRowElement(MarkdownElement):
283 """MarkdownElement corresponding to `tr_open` and `tr_close`."""
285 def __init__(self) -> None:
286 self.cells: list[TableDataElement] = []
288 def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:
289 assert isinstance(child, TableDataElement)
290 self.cells.append(child)
291 return False
294class TableDataElement(MarkdownElement):
295 """MarkdownElement corresponding to `td_open` and `td_close`
296 and `th_open` and `th_close`."""
298 @classmethod
299 def create(cls, markdown: Markdown, token: Token) -> MarkdownElement:
300 style = str(token.attrs.get("style")) or ""
302 justify: JustifyMethod
303 if "text-align:right" in style:
304 justify = "right"
305 elif "text-align:center" in style:
306 justify = "center"
307 elif "text-align:left" in style:
308 justify = "left"
309 else:
310 justify = "default"
312 assert justify in get_args(JustifyMethod)
313 return cls(justify=justify)
315 def __init__(self, justify: JustifyMethod) -> None:
316 self.content: Text = Text("", justify=justify)
317 self.justify = justify
319 def on_text(self, context: MarkdownContext, text: TextType) -> None:
320 text = Text(text) if isinstance(text, str) else text
321 text.stylize(context.current_style)
322 self.content.append_text(text)
325class ListElement(MarkdownElement):
326 """A list element."""
328 @classmethod
329 def create(cls, markdown: Markdown, token: Token) -> ListElement:
330 return cls(token.type, int(token.attrs.get("start", 1)))
332 def __init__(self, list_type: str, list_start: int | None) -> None:
333 self.items: list[ListItem] = []
334 self.list_type = list_type
335 self.list_start = list_start
337 def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:
338 assert isinstance(child, ListItem)
339 self.items.append(child)
340 return False
342 def __rich_console__(
343 self, console: Console, options: ConsoleOptions
344 ) -> RenderResult:
345 if self.list_type == "bullet_list_open":
346 for item in self.items:
347 yield from item.render_bullet(console, options)
348 else:
349 number = 1 if self.list_start is None else self.list_start
350 last_number = number + len(self.items)
351 for index, item in enumerate(self.items):
352 yield from item.render_number(
353 console, options, number + index, last_number
354 )
357class ListItem(TextElement):
358 """An item in a list."""
360 style_name = "markdown.item"
362 def __init__(self) -> None:
363 self.elements: Renderables = Renderables()
365 def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:
366 self.elements.append(child)
367 return False
369 def render_bullet(self, console: Console, options: ConsoleOptions) -> RenderResult:
370 render_options = options.update(width=options.max_width - 3)
371 lines = console.render_lines(self.elements, render_options, style=self.style)
372 bullet_style = console.get_style("markdown.item.bullet", default="none")
374 bullet = Segment(" • ", bullet_style)
375 padding = Segment(" " * 3, bullet_style)
376 new_line = Segment("\n")
377 for first, line in loop_first(lines):
378 yield bullet if first else padding
379 yield from line
380 yield new_line
382 def render_number(
383 self, console: Console, options: ConsoleOptions, number: int, last_number: int
384 ) -> RenderResult:
385 number_width = len(str(last_number)) + 2
386 render_options = options.update(width=options.max_width - number_width)
387 lines = console.render_lines(self.elements, render_options, style=self.style)
388 number_style = console.get_style("markdown.item.number", default="none")
390 new_line = Segment("\n")
391 padding = Segment(" " * number_width, number_style)
392 numeral = Segment(f"{number}".rjust(number_width - 1) + " ", number_style)
393 for first, line in loop_first(lines):
394 yield numeral if first else padding
395 yield from line
396 yield new_line
399class Link(TextElement):
400 @classmethod
401 def create(cls, markdown: Markdown, token: Token) -> MarkdownElement:
402 url = token.attrs.get("href", "#")
403 return cls(token.content, str(url))
405 def __init__(self, text: str, href: str):
406 self.text = Text(text)
407 self.href = href
410class ImageItem(TextElement):
411 """Renders a placeholder for an image."""
413 new_line = False
415 @classmethod
416 def create(cls, markdown: Markdown, token: Token) -> MarkdownElement:
417 """Factory to create markdown element,
419 Args:
420 markdown (Markdown): The parent Markdown object.
421 token (Any): A token from markdown-it.
423 Returns:
424 MarkdownElement: A new markdown element
425 """
426 return cls(str(token.attrs.get("src", "")), markdown.hyperlinks)
428 def __init__(self, destination: str, hyperlinks: bool) -> None:
429 self.destination = destination
430 self.hyperlinks = hyperlinks
431 self.link: str | None = None
432 super().__init__()
434 def on_enter(self, context: MarkdownContext) -> None:
435 self.link = context.current_style.link
436 self.text = Text(justify="left")
437 super().on_enter(context)
439 def __rich_console__(
440 self, console: Console, options: ConsoleOptions
441 ) -> RenderResult:
442 link_style = Style(link=self.link or self.destination or None)
443 title = self.text or Text(self.destination.strip("/").rsplit("/", 1)[-1])
444 if self.hyperlinks:
445 title.stylize(link_style)
446 text = Text.assemble("🌆 ", title, " ", end="")
447 yield text
450class MarkdownContext:
451 """Manages the console render state."""
453 def __init__(
454 self,
455 console: Console,
456 options: ConsoleOptions,
457 style: Style,
458 inline_code_lexer: str | None = None,
459 inline_code_theme: str = "monokai",
460 ) -> None:
461 self.console = console
462 self.options = options
463 self.style_stack: StyleStack = StyleStack(style)
464 self.stack: Stack[MarkdownElement] = Stack()
466 self._syntax: Syntax | None = None
467 if inline_code_lexer is not None:
468 self._syntax = Syntax("", inline_code_lexer, theme=inline_code_theme)
470 @property
471 def current_style(self) -> Style:
472 """Current style which is the product of all styles on the stack."""
473 return self.style_stack.current
475 def on_text(self, text: str, node_type: str) -> None:
476 """Called when the parser visits text."""
477 if node_type in {"fence", "code_inline"} and self._syntax is not None:
478 highlight_text = self._syntax.highlight(text)
479 highlight_text.rstrip()
480 self.stack.top.on_text(
481 self, Text.assemble(highlight_text, style=self.style_stack.current)
482 )
483 else:
484 self.stack.top.on_text(self, text)
486 def enter_style(self, style_name: str | Style) -> Style:
487 """Enter a style context."""
488 style = self.console.get_style(style_name, default="none")
489 self.style_stack.push(style)
490 return self.current_style
492 def leave_style(self) -> Style:
493 """Leave a style context."""
494 style = self.style_stack.pop()
495 return style
498class Markdown(JupyterMixin):
499 """A Markdown renderable.
501 Args:
502 markup (str): A string containing markdown.
503 code_theme (str, optional): Pygments theme for code blocks. Defaults to "monokai". See https://pygments.org/styles/ for code themes.
504 justify (JustifyMethod, optional): Justify value for paragraphs. Defaults to None.
505 style (Union[str, Style], optional): Optional style to apply to markdown.
506 hyperlinks (bool, optional): Enable hyperlinks. Defaults to ``True``.
507 inline_code_lexer: (str, optional): Lexer to use if inline code highlighting is
508 enabled. Defaults to None.
509 inline_code_theme: (Optional[str], optional): Pygments theme for inline code
510 highlighting, or None for no highlighting. Defaults to None.
511 """
513 elements: ClassVar[dict[str, type[MarkdownElement]]] = {
514 "paragraph_open": Paragraph,
515 "heading_open": Heading,
516 "fence": CodeBlock,
517 "code_block": CodeBlock,
518 "blockquote_open": BlockQuote,
519 "hr": HorizontalRule,
520 "bullet_list_open": ListElement,
521 "ordered_list_open": ListElement,
522 "list_item_open": ListItem,
523 "image": ImageItem,
524 "table_open": TableElement,
525 "tbody_open": TableBodyElement,
526 "thead_open": TableHeaderElement,
527 "tr_open": TableRowElement,
528 "td_open": TableDataElement,
529 "th_open": TableDataElement,
530 }
532 inlines = {"em", "strong", "code", "s"}
534 def __init__(
535 self,
536 markup: str,
537 code_theme: str = "monokai",
538 justify: JustifyMethod | None = None,
539 style: str | Style = "none",
540 hyperlinks: bool = True,
541 inline_code_lexer: str | None = None,
542 inline_code_theme: str | None = None,
543 ) -> None:
544 parser = MarkdownIt().enable("strikethrough").enable("table")
545 self.markup = markup
546 self.parsed = parser.parse(markup)
547 self.code_theme = code_theme
548 self.justify: JustifyMethod | None = justify
549 self.style = style
550 self.hyperlinks = hyperlinks
551 self.inline_code_lexer = inline_code_lexer
552 self.inline_code_theme = inline_code_theme or code_theme
554 def _flatten_tokens(self, tokens: Iterable[Token]) -> Iterable[Token]:
555 """Flattens the token stream."""
556 for token in tokens:
557 is_fence = token.type == "fence"
558 is_image = token.tag == "img"
559 if token.children and not (is_image or is_fence):
560 yield from self._flatten_tokens(token.children)
561 else:
562 yield token
564 def __rich_console__(
565 self, console: Console, options: ConsoleOptions
566 ) -> RenderResult:
567 """Render markdown to the console."""
568 style = console.get_style(self.style, default="none")
569 options = options.update(height=None)
570 context = MarkdownContext(
571 console,
572 options,
573 style,
574 inline_code_lexer=self.inline_code_lexer,
575 inline_code_theme=self.inline_code_theme,
576 )
577 tokens = self.parsed
578 inline_style_tags = self.inlines
579 new_line = False
580 _new_line_segment = Segment.line()
582 for token in self._flatten_tokens(tokens):
583 node_type = token.type
584 tag = token.tag
586 entering = token.nesting == 1
587 exiting = token.nesting == -1
588 self_closing = token.nesting == 0
590 if node_type == "text":
591 context.on_text(token.content, node_type)
592 elif node_type == "hardbreak":
593 context.on_text("\n", node_type)
594 elif node_type == "softbreak":
595 context.on_text(" ", node_type)
596 elif node_type == "link_open":
597 href = str(token.attrs.get("href", ""))
598 if self.hyperlinks:
599 link_style = console.get_style("markdown.link_url", default="none")
600 link_style += Style(link=href)
601 context.enter_style(link_style)
602 else:
603 context.stack.push(Link.create(self, token))
604 elif node_type == "link_close":
605 if self.hyperlinks:
606 context.leave_style()
607 else:
608 element = context.stack.pop()
609 assert isinstance(element, Link)
610 link_style = console.get_style("markdown.link", default="none")
611 context.enter_style(link_style)
612 context.on_text(element.text.plain, node_type)
613 context.leave_style()
614 context.on_text(" (", node_type)
615 link_url_style = console.get_style(
616 "markdown.link_url", default="none"
617 )
618 context.enter_style(link_url_style)
619 context.on_text(element.href, node_type)
620 context.leave_style()
621 context.on_text(")", node_type)
622 elif (
623 tag in inline_style_tags
624 and node_type != "fence"
625 and node_type != "code_block"
626 ):
627 if entering:
628 # If it's an opening inline token e.g. strong, em, etc.
629 # Then we move into a style context i.e. push to stack.
630 context.enter_style(f"markdown.{tag}")
631 elif exiting:
632 # If it's a closing inline style, then we pop the style
633 # off of the stack, to move out of the context of it...
634 context.leave_style()
635 else:
636 # If it's a self-closing inline style e.g. `code_inline`
637 context.enter_style(f"markdown.{tag}")
638 if token.content:
639 context.on_text(token.content, node_type)
640 context.leave_style()
641 else:
642 # Map the markdown tag -> MarkdownElement renderable
643 element_class = self.elements.get(token.type) or UnknownElement
644 element = element_class.create(self, token)
646 if entering or self_closing:
647 context.stack.push(element)
648 element.on_enter(context)
650 if exiting: # CLOSING tag
651 element = context.stack.pop()
653 should_render = not context.stack or (
654 context.stack
655 and context.stack.top.on_child_close(context, element)
656 )
658 if should_render:
659 if new_line:
660 yield _new_line_segment
662 yield from console.render(element, context.options)
663 elif self_closing: # SELF-CLOSING tags (e.g. text, code, image)
664 context.stack.pop()
665 text = token.content
666 if text is not None:
667 element.on_text(context, text)
669 should_render = (
670 not context.stack
671 or context.stack
672 and context.stack.top.on_child_close(context, element)
673 )
674 if should_render:
675 if new_line and node_type != "inline":
676 yield _new_line_segment
677 yield from console.render(element, context.options)
679 if exiting or self_closing:
680 element.on_leave(context)
681 new_line = element.new_line
684if __name__ == "__main__": # pragma: no cover
685 import argparse
686 import sys
688 parser = argparse.ArgumentParser(
689 description="Render Markdown to the console with Rich"
690 )
691 parser.add_argument(
692 "path",
693 metavar="PATH",
694 help="path to markdown file, or - for stdin",
695 )
696 parser.add_argument(
697 "-c",
698 "--force-color",
699 dest="force_color",
700 action="store_true",
701 default=None,
702 help="force color for non-terminals",
703 )
704 parser.add_argument(
705 "-t",
706 "--code-theme",
707 dest="code_theme",
708 default="monokai",
709 help="pygments code theme",
710 )
711 parser.add_argument(
712 "-i",
713 "--inline-code-lexer",
714 dest="inline_code_lexer",
715 default=None,
716 help="inline_code_lexer",
717 )
718 parser.add_argument(
719 "-y",
720 "--hyperlinks",
721 dest="hyperlinks",
722 action="store_true",
723 help="enable hyperlinks",
724 )
725 parser.add_argument(
726 "-w",
727 "--width",
728 type=int,
729 dest="width",
730 default=None,
731 help="width of output (default will auto-detect)",
732 )
733 parser.add_argument(
734 "-j",
735 "--justify",
736 dest="justify",
737 action="store_true",
738 help="enable full text justify",
739 )
740 parser.add_argument(
741 "-p",
742 "--page",
743 dest="page",
744 action="store_true",
745 help="use pager to scroll output",
746 )
747 args = parser.parse_args()
749 from rich.console import Console
751 if args.path == "-":
752 markdown_body = sys.stdin.read()
753 else:
754 with open(args.path, encoding="utf-8") as markdown_file:
755 markdown_body = markdown_file.read()
757 markdown = Markdown(
758 markdown_body,
759 justify="full" if args.justify else "left",
760 code_theme=args.code_theme,
761 hyperlinks=args.hyperlinks,
762 inline_code_lexer=args.inline_code_lexer,
763 )
764 if args.page:
765 import io
766 import pydoc
768 fileio = io.StringIO()
769 console = Console(
770 file=fileio, force_terminal=args.force_color, width=args.width
771 )
772 console.print(markdown)
773 pydoc.pager(fileio.getvalue())
775 else:
776 console = Console(
777 force_terminal=args.force_color, width=args.width, record=True
778 )
779 console.print(markdown)