Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/rich/markdown.py: 64%
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 text = Text(text) if isinstance(text, str) else text
335 text.stylize(context.current_style)
336 self.content.append_text(text)
339class ListElement(MarkdownElement):
340 """A list element."""
342 @classmethod
343 def create(cls, markdown: Markdown, token: Token) -> ListElement:
344 return cls(token.type, int(token.attrs.get("start", 1)))
346 def __init__(self, list_type: str, list_start: int | None) -> None:
347 self.items: list[ListItem] = []
348 self.list_type = list_type
349 self.list_start = list_start
351 def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:
352 assert isinstance(child, ListItem)
353 self.items.append(child)
354 return False
356 def __rich_console__(
357 self, console: Console, options: ConsoleOptions
358 ) -> RenderResult:
359 if self.list_type == "bullet_list_open":
360 for item in self.items:
361 yield from item.render_bullet(console, options)
362 else:
363 number = 1 if self.list_start is None else self.list_start
364 last_number = number + len(self.items)
365 for index, item in enumerate(self.items):
366 yield from item.render_number(
367 console, options, number + index, last_number
368 )
371class ListItem(TextElement):
372 """An item in a list."""
374 style_name = "markdown.item"
376 def __init__(self) -> None:
377 self.elements: Renderables = Renderables()
379 def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:
380 self.elements.append(child)
381 return False
383 def render_bullet(self, console: Console, options: ConsoleOptions) -> RenderResult:
384 render_options = options.update(width=options.max_width - 3)
385 lines = console.render_lines(self.elements, render_options, style=self.style)
386 bullet_style = console.get_style("markdown.item.bullet", default="none")
388 bullet = Segment(" • ", bullet_style)
389 padding = Segment(" " * 3, bullet_style)
390 new_line = Segment("\n")
391 for first, line in loop_first(lines):
392 yield bullet if first else padding
393 yield from line
394 yield new_line
396 def render_number(
397 self, console: Console, options: ConsoleOptions, number: int, last_number: int
398 ) -> RenderResult:
399 number_width = len(str(last_number)) + 2
400 render_options = options.update(width=options.max_width - number_width)
401 lines = console.render_lines(self.elements, render_options, style=self.style)
402 number_style = console.get_style("markdown.item.number", default="none")
404 new_line = Segment("\n")
405 padding = Segment(" " * number_width, number_style)
406 numeral = Segment(f"{number}".rjust(number_width - 1) + " ", number_style)
407 for first, line in loop_first(lines):
408 yield numeral if first else padding
409 yield from line
410 yield new_line
413class Link(TextElement):
414 @classmethod
415 def create(cls, markdown: Markdown, token: Token) -> MarkdownElement:
416 url = token.attrs.get("href", "#")
417 return cls(token.content, str(url))
419 def __init__(self, text: str, href: str):
420 self.text = Text(text)
421 self.href = href
424class ImageItem(TextElement):
425 """Renders a placeholder for an image."""
427 new_line = False
429 @classmethod
430 def create(cls, markdown: Markdown, token: Token) -> MarkdownElement:
431 """Factory to create markdown element,
433 Args:
434 markdown (Markdown): The parent Markdown object.
435 token (Any): A token from markdown-it.
437 Returns:
438 MarkdownElement: A new markdown element
439 """
440 return cls(str(token.attrs.get("src", "")), markdown.hyperlinks)
442 def __init__(self, destination: str, hyperlinks: bool) -> None:
443 self.destination = destination
444 self.hyperlinks = hyperlinks
445 self.link: str | None = None
446 super().__init__()
448 def on_enter(self, context: MarkdownContext) -> None:
449 self.link = context.current_style.link
450 self.text = Text(justify="left")
451 super().on_enter(context)
453 def __rich_console__(
454 self, console: Console, options: ConsoleOptions
455 ) -> RenderResult:
456 link_style = Style(link=self.link or self.destination or None)
457 title = self.text or Text(self.destination.strip("/").rsplit("/", 1)[-1])
458 if self.hyperlinks:
459 title.stylize(link_style)
460 text = Text.assemble("🌆 ", title, " ", end="")
461 yield text
464class MarkdownContext:
465 """Manages the console render state."""
467 def __init__(
468 self,
469 console: Console,
470 options: ConsoleOptions,
471 style: Style,
472 inline_code_lexer: str | None = None,
473 inline_code_theme: str = "monokai",
474 ) -> None:
475 self.console = console
476 self.options = options
477 self.style_stack: StyleStack = StyleStack(style)
478 self.stack: Stack[MarkdownElement] = Stack()
480 self._syntax: Syntax | None = None
481 if inline_code_lexer is not None:
482 self._syntax = Syntax("", inline_code_lexer, theme=inline_code_theme)
484 @property
485 def current_style(self) -> Style:
486 """Current style which is the product of all styles on the stack."""
487 return self.style_stack.current
489 def on_text(self, text: str, node_type: str) -> None:
490 """Called when the parser visits text."""
491 if node_type in {"fence", "code_inline"} and self._syntax is not None:
492 highlight_text = self._syntax.highlight(text)
493 highlight_text.rstrip()
494 self.stack.top.on_text(
495 self, Text.assemble(highlight_text, style=self.style_stack.current)
496 )
497 else:
498 self.stack.top.on_text(self, text)
500 def enter_style(self, style_name: str | Style) -> Style:
501 """Enter a style context."""
502 style = self.console.get_style(style_name, default="none")
503 self.style_stack.push(style)
504 return self.current_style
506 def leave_style(self) -> Style:
507 """Leave a style context."""
508 style = self.style_stack.pop()
509 return style
512class Markdown(JupyterMixin):
513 """A Markdown renderable.
515 Args:
516 markup (str): A string containing markdown.
517 code_theme (str, optional): Pygments theme for code blocks. Defaults to "monokai". See https://pygments.org/styles/ for code themes.
518 justify (JustifyMethod, optional): Justify value for paragraphs. Defaults to None.
519 style (Union[str, Style], optional): Optional style to apply to markdown.
520 hyperlinks (bool, optional): Enable hyperlinks. Defaults to ``True``.
521 inline_code_lexer: (str, optional): Lexer to use if inline code highlighting is
522 enabled. Defaults to None.
523 inline_code_theme: (Optional[str], optional): Pygments theme for inline code
524 highlighting, or None for no highlighting. Defaults to None.
525 """
527 elements: ClassVar[dict[str, type[MarkdownElement]]] = {
528 "paragraph_open": Paragraph,
529 "heading_open": Heading,
530 "fence": CodeBlock,
531 "code_block": CodeBlock,
532 "blockquote_open": BlockQuote,
533 "hr": HorizontalRule,
534 "bullet_list_open": ListElement,
535 "ordered_list_open": ListElement,
536 "list_item_open": ListItem,
537 "image": ImageItem,
538 "table_open": TableElement,
539 "tbody_open": TableBodyElement,
540 "thead_open": TableHeaderElement,
541 "tr_open": TableRowElement,
542 "td_open": TableDataElement,
543 "th_open": TableDataElement,
544 }
546 inlines = {"em", "strong", "code", "s"}
548 def __init__(
549 self,
550 markup: str,
551 code_theme: str = "monokai",
552 justify: JustifyMethod | None = None,
553 style: str | Style = "none",
554 hyperlinks: bool = True,
555 inline_code_lexer: str | None = None,
556 inline_code_theme: str | None = None,
557 ) -> None:
558 parser = MarkdownIt().enable("strikethrough").enable("table")
559 self.markup = markup
560 self.parsed = parser.parse(markup)
561 self.code_theme = code_theme
562 self.justify: JustifyMethod | None = justify
563 self.style = style
564 self.hyperlinks = hyperlinks
565 self.inline_code_lexer = inline_code_lexer
566 self.inline_code_theme = inline_code_theme or code_theme
568 def _flatten_tokens(self, tokens: Iterable[Token]) -> Iterable[Token]:
569 """Flattens the token stream."""
570 for token in tokens:
571 is_fence = token.type == "fence"
572 is_image = token.tag == "img"
573 if token.children and not (is_image or is_fence):
574 yield from self._flatten_tokens(token.children)
575 else:
576 yield token
578 def __rich_console__(
579 self, console: Console, options: ConsoleOptions
580 ) -> RenderResult:
581 """Render markdown to the console."""
582 style = console.get_style(self.style, default="none")
583 options = options.update(height=None)
584 context = MarkdownContext(
585 console,
586 options,
587 style,
588 inline_code_lexer=self.inline_code_lexer,
589 inline_code_theme=self.inline_code_theme,
590 )
591 tokens = self.parsed
592 inline_style_tags = self.inlines
593 new_line = False
594 _new_line_segment = Segment.line()
596 for token in self._flatten_tokens(tokens):
597 node_type = token.type
598 tag = token.tag
600 entering = token.nesting == 1
601 exiting = token.nesting == -1
602 self_closing = token.nesting == 0
604 if node_type == "text":
605 context.on_text(token.content, node_type)
606 elif node_type == "hardbreak":
607 context.on_text("\n", node_type)
608 elif node_type == "softbreak":
609 context.on_text(" ", node_type)
610 elif node_type == "link_open":
611 href = str(token.attrs.get("href", ""))
612 if self.hyperlinks:
613 link_style = console.get_style("markdown.link_url", default="none")
614 link_style += Style(link=href)
615 context.enter_style(link_style)
616 else:
617 context.stack.push(Link.create(self, token))
618 elif node_type == "link_close":
619 if self.hyperlinks:
620 context.leave_style()
621 else:
622 element = context.stack.pop()
623 assert isinstance(element, Link)
624 link_style = console.get_style("markdown.link", default="none")
625 context.enter_style(link_style)
626 context.on_text(element.text.plain, node_type)
627 context.leave_style()
628 context.on_text(" (", node_type)
629 link_url_style = console.get_style(
630 "markdown.link_url", default="none"
631 )
632 context.enter_style(link_url_style)
633 context.on_text(element.href, node_type)
634 context.leave_style()
635 context.on_text(")", node_type)
636 elif (
637 tag in inline_style_tags
638 and node_type != "fence"
639 and node_type != "code_block"
640 ):
641 if entering:
642 # If it's an opening inline token e.g. strong, em, etc.
643 # Then we move into a style context i.e. push to stack.
644 context.enter_style(f"markdown.{tag}")
645 elif exiting:
646 # If it's a closing inline style, then we pop the style
647 # off of the stack, to move out of the context of it...
648 context.leave_style()
649 else:
650 # If it's a self-closing inline style e.g. `code_inline`
651 context.enter_style(f"markdown.{tag}")
652 if token.content:
653 context.on_text(token.content, node_type)
654 context.leave_style()
655 else:
656 # Map the markdown tag -> MarkdownElement renderable
657 element_class = self.elements.get(token.type) or UnknownElement
658 element = element_class.create(self, token)
660 if entering or self_closing:
661 context.stack.push(element)
662 element.on_enter(context)
664 if exiting: # CLOSING tag
665 element = context.stack.pop()
667 should_render = not context.stack or (
668 context.stack
669 and context.stack.top.on_child_close(context, element)
670 )
672 if should_render:
673 if new_line:
674 yield _new_line_segment
676 yield from console.render(element, context.options)
677 elif self_closing: # SELF-CLOSING tags (e.g. text, code, image)
678 context.stack.pop()
679 text = token.content
680 if text is not None:
681 element.on_text(context, text)
683 should_render = (
684 not context.stack
685 or context.stack
686 and context.stack.top.on_child_close(context, element)
687 )
688 if should_render:
689 if new_line and node_type != "inline":
690 yield _new_line_segment
691 yield from console.render(element, context.options)
693 if exiting or self_closing:
694 element.on_leave(context)
695 new_line = element.new_line
698if __name__ == "__main__": # pragma: no cover
699 import argparse
700 import sys
702 parser = argparse.ArgumentParser(
703 description="Render Markdown to the console with Rich"
704 )
705 parser.add_argument(
706 "path",
707 metavar="PATH",
708 help="path to markdown file, or - for stdin",
709 )
710 parser.add_argument(
711 "-c",
712 "--force-color",
713 dest="force_color",
714 action="store_true",
715 default=None,
716 help="force color for non-terminals",
717 )
718 parser.add_argument(
719 "-t",
720 "--code-theme",
721 dest="code_theme",
722 default="monokai",
723 help="pygments code theme",
724 )
725 parser.add_argument(
726 "-i",
727 "--inline-code-lexer",
728 dest="inline_code_lexer",
729 default=None,
730 help="inline_code_lexer",
731 )
732 parser.add_argument(
733 "-y",
734 "--hyperlinks",
735 dest="hyperlinks",
736 action="store_true",
737 help="enable hyperlinks",
738 )
739 parser.add_argument(
740 "-w",
741 "--width",
742 type=int,
743 dest="width",
744 default=None,
745 help="width of output (default will auto-detect)",
746 )
747 parser.add_argument(
748 "-j",
749 "--justify",
750 dest="justify",
751 action="store_true",
752 help="enable full text justify",
753 )
754 parser.add_argument(
755 "-p",
756 "--page",
757 dest="page",
758 action="store_true",
759 help="use pager to scroll output",
760 )
761 args = parser.parse_args()
763 from rich.console import Console
765 if args.path == "-":
766 markdown_body = sys.stdin.read()
767 else:
768 with open(args.path, encoding="utf-8") as markdown_file:
769 markdown_body = markdown_file.read()
771 markdown = Markdown(
772 markdown_body,
773 justify="full" if args.justify else "left",
774 code_theme=args.code_theme,
775 hyperlinks=args.hyperlinks,
776 inline_code_lexer=args.inline_code_lexer,
777 )
778 if args.page:
779 import io
780 import pydoc
782 fileio = io.StringIO()
783 console = Console(
784 file=fileio, force_terminal=args.force_color, width=args.width
785 )
786 console.print(markdown)
787 pydoc.pager(fileio.getvalue())
789 else:
790 console = Console(
791 force_terminal=args.force_color, width=args.width, record=True
792 )
793 console.print(markdown)