Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.8/site-packages/rich/markdown.py: 94%
370 statements
« prev ^ index » next coverage.py v7.3.1, created at 2023-09-18 06:13 +0000
« prev ^ index » next coverage.py v7.3.1, created at 2023-09-18 06:13 +0000
1from __future__ import annotations
3import sys
4from typing import ClassVar, Dict, Iterable, List, Optional, Type, Union
6from markdown_it import MarkdownIt
7from markdown_it.token import Token
9if sys.version_info >= (3, 8):
10 from typing import get_args
11else:
12 from typing_extensions import get_args # pragma: no cover
14from rich.table import Table
16from . import box
17from ._loop import loop_first
18from ._stack import Stack
19from .console import Console, ConsoleOptions, JustifyMethod, RenderResult
20from .containers import Renderables
21from .jupyter import JupyterMixin
22from .panel import Panel
23from .rule import Rule
24from .segment import Segment
25from .style import Style, StyleStack
26from .syntax import Syntax
27from .text import Text, TextType
30class MarkdownElement:
31 new_line: ClassVar[bool] = True
33 @classmethod
34 def create(cls, markdown: "Markdown", token: Token) -> "MarkdownElement":
35 """Factory to create markdown element,
37 Args:
38 markdown (Markdown): The parent Markdown object.
39 token (Token): A node from markdown-it.
41 Returns:
42 MarkdownElement: A new markdown element
43 """
44 return cls()
46 def on_enter(self, context: "MarkdownContext") -> None:
47 """Called when the node is entered.
49 Args:
50 context (MarkdownContext): The markdown context.
51 """
53 def on_text(self, context: "MarkdownContext", text: TextType) -> None:
54 """Called when text is parsed.
56 Args:
57 context (MarkdownContext): The markdown context.
58 """
60 def on_leave(self, context: "MarkdownContext") -> None:
61 """Called when the parser leaves the element.
63 Args:
64 context (MarkdownContext): [description]
65 """
67 def on_child_close(
68 self, context: "MarkdownContext", child: "MarkdownElement"
69 ) -> bool:
70 """Called when a child element is closed.
72 This method allows a parent element to take over rendering of its children.
74 Args:
75 context (MarkdownContext): The markdown context.
76 child (MarkdownElement): The child markdown element.
78 Returns:
79 bool: Return True to render the element, or False to not render the element.
80 """
81 return True
83 def __rich_console__(
84 self, console: "Console", options: "ConsoleOptions"
85 ) -> "RenderResult":
86 return ()
89class UnknownElement(MarkdownElement):
90 """An unknown element.
92 Hopefully there will be no unknown elements, and we will have a MarkdownElement for
93 everything in the document.
95 """
98class TextElement(MarkdownElement):
99 """Base class for elements that render text."""
101 style_name = "none"
103 def on_enter(self, context: "MarkdownContext") -> None:
104 self.style = context.enter_style(self.style_name)
105 self.text = Text(justify="left")
107 def on_text(self, context: "MarkdownContext", text: TextType) -> None:
108 self.text.append(text, context.current_style if isinstance(text, str) else None)
110 def on_leave(self, context: "MarkdownContext") -> None:
111 context.leave_style()
114class Paragraph(TextElement):
115 """A Paragraph."""
117 style_name = "markdown.paragraph"
118 justify: JustifyMethod
120 @classmethod
121 def create(cls, markdown: "Markdown", token: Token) -> "Paragraph":
122 return cls(justify=markdown.justify or "left")
124 def __init__(self, justify: JustifyMethod) -> None:
125 self.justify = justify
127 def __rich_console__(
128 self, console: Console, options: ConsoleOptions
129 ) -> RenderResult:
130 self.text.justify = self.justify
131 yield self.text
134class Heading(TextElement):
135 """A heading."""
137 @classmethod
138 def create(cls, markdown: "Markdown", token: Token) -> "Heading":
139 return cls(token.tag)
141 def on_enter(self, context: "MarkdownContext") -> None:
142 self.text = Text()
143 context.enter_style(self.style_name)
145 def __init__(self, tag: str) -> None:
146 self.tag = tag
147 self.style_name = f"markdown.{tag}"
148 super().__init__()
150 def __rich_console__(
151 self, console: Console, options: ConsoleOptions
152 ) -> RenderResult:
153 text = self.text
154 text.justify = "center"
155 if self.tag == "h1":
156 # Draw a border around h1s
157 yield Panel(
158 text,
159 box=box.HEAVY,
160 style="markdown.h1.border",
161 )
162 else:
163 # Styled text for h2 and beyond
164 if self.tag == "h2":
165 yield Text("")
166 yield text
169class CodeBlock(TextElement):
170 """A code block with syntax highlighting."""
172 style_name = "markdown.code_block"
174 @classmethod
175 def create(cls, markdown: "Markdown", token: Token) -> "CodeBlock":
176 node_info = token.info or ""
177 lexer_name = node_info.partition(" ")[0]
178 return cls(lexer_name or "text", markdown.code_theme)
180 def __init__(self, lexer_name: str, theme: str) -> None:
181 self.lexer_name = lexer_name
182 self.theme = theme
184 def __rich_console__(
185 self, console: Console, options: ConsoleOptions
186 ) -> RenderResult:
187 code = str(self.text).rstrip()
188 syntax = Syntax(
189 code, self.lexer_name, theme=self.theme, word_wrap=True, padding=1
190 )
191 yield syntax
194class BlockQuote(TextElement):
195 """A block quote."""
197 style_name = "markdown.block_quote"
199 def __init__(self) -> None:
200 self.elements: Renderables = Renderables()
202 def on_child_close(
203 self, context: "MarkdownContext", child: "MarkdownElement"
204 ) -> bool:
205 self.elements.append(child)
206 return False
208 def __rich_console__(
209 self, console: Console, options: ConsoleOptions
210 ) -> RenderResult:
211 render_options = options.update(width=options.max_width - 4)
212 lines = console.render_lines(self.elements, render_options, style=self.style)
213 style = self.style
214 new_line = Segment("\n")
215 padding = Segment("▌ ", style)
216 for line in lines:
217 yield padding
218 yield from line
219 yield new_line
222class HorizontalRule(MarkdownElement):
223 """A horizontal rule to divide sections."""
225 new_line = False
227 def __rich_console__(
228 self, console: Console, options: ConsoleOptions
229 ) -> RenderResult:
230 style = console.get_style("markdown.hr", default="none")
231 yield Rule(style=style)
234class TableElement(MarkdownElement):
235 """MarkdownElement corresponding to `table_open`."""
237 def __init__(self) -> None:
238 self.header: TableHeaderElement | None = None
239 self.body: TableBodyElement | None = None
241 def on_child_close(
242 self, context: "MarkdownContext", child: "MarkdownElement"
243 ) -> bool:
244 if isinstance(child, TableHeaderElement):
245 self.header = child
246 elif isinstance(child, TableBodyElement):
247 self.body = child
248 else:
249 raise RuntimeError("Couldn't process markdown table.")
250 return False
252 def __rich_console__(
253 self, console: Console, options: ConsoleOptions
254 ) -> RenderResult:
255 table = Table(box=box.SIMPLE_HEAVY)
257 if self.header is not None and self.header.row is not None:
258 for column in self.header.row.cells:
259 table.add_column(column.content)
261 if self.body is not None:
262 for row in self.body.rows:
263 row_content = [element.content for element in row.cells]
264 table.add_row(*row_content)
266 yield table
269class TableHeaderElement(MarkdownElement):
270 """MarkdownElement corresponding to `thead_open` and `thead_close`."""
272 def __init__(self) -> None:
273 self.row: TableRowElement | None = None
275 def on_child_close(
276 self, context: "MarkdownContext", child: "MarkdownElement"
277 ) -> bool:
278 assert isinstance(child, TableRowElement)
279 self.row = child
280 return False
283class TableBodyElement(MarkdownElement):
284 """MarkdownElement corresponding to `tbody_open` and `tbody_close`."""
286 def __init__(self) -> None:
287 self.rows: list[TableRowElement] = []
289 def on_child_close(
290 self, context: "MarkdownContext", child: "MarkdownElement"
291 ) -> bool:
292 assert isinstance(child, TableRowElement)
293 self.rows.append(child)
294 return False
297class TableRowElement(MarkdownElement):
298 """MarkdownElement corresponding to `tr_open` and `tr_close`."""
300 def __init__(self) -> None:
301 self.cells: List[TableDataElement] = []
303 def on_child_close(
304 self, context: "MarkdownContext", child: "MarkdownElement"
305 ) -> bool:
306 assert isinstance(child, TableDataElement)
307 self.cells.append(child)
308 return False
311class TableDataElement(MarkdownElement):
312 """MarkdownElement corresponding to `td_open` and `td_close`
313 and `th_open` and `th_close`."""
315 @classmethod
316 def create(cls, markdown: "Markdown", token: Token) -> "MarkdownElement":
317 style = str(token.attrs.get("style")) or ""
319 justify: JustifyMethod
320 if "text-align:right" in style:
321 justify = "right"
322 elif "text-align:center" in style:
323 justify = "center"
324 elif "text-align:left" in style:
325 justify = "left"
326 else:
327 justify = "default"
329 assert justify in get_args(JustifyMethod)
330 return cls(justify=justify)
332 def __init__(self, justify: JustifyMethod) -> None:
333 self.content: Text = Text("", justify=justify)
334 self.justify = justify
336 def on_text(self, context: "MarkdownContext", text: TextType) -> None:
337 text = Text(text) if isinstance(text, str) else text
338 text.stylize(context.current_style)
339 self.content.append_text(text)
342class ListElement(MarkdownElement):
343 """A list element."""
345 @classmethod
346 def create(cls, markdown: "Markdown", token: Token) -> "ListElement":
347 return cls(token.type, int(token.attrs.get("start", 1)))
349 def __init__(self, list_type: str, list_start: int | None) -> None:
350 self.items: List[ListItem] = []
351 self.list_type = list_type
352 self.list_start = list_start
354 def on_child_close(
355 self, context: "MarkdownContext", child: "MarkdownElement"
356 ) -> bool:
357 assert isinstance(child, ListItem)
358 self.items.append(child)
359 return False
361 def __rich_console__(
362 self, console: Console, options: ConsoleOptions
363 ) -> RenderResult:
364 if self.list_type == "bullet_list_open":
365 for item in self.items:
366 yield from item.render_bullet(console, options)
367 else:
368 number = 1 if self.list_start is None else self.list_start
369 last_number = number + len(self.items)
370 for index, item in enumerate(self.items):
371 yield from item.render_number(
372 console, options, number + index, last_number
373 )
376class ListItem(TextElement):
377 """An item in a list."""
379 style_name = "markdown.item"
381 def __init__(self) -> None:
382 self.elements: Renderables = Renderables()
384 def on_child_close(
385 self, context: "MarkdownContext", child: "MarkdownElement"
386 ) -> bool:
387 self.elements.append(child)
388 return False
390 def render_bullet(self, console: Console, options: ConsoleOptions) -> RenderResult:
391 render_options = options.update(width=options.max_width - 3)
392 lines = console.render_lines(self.elements, render_options, style=self.style)
393 bullet_style = console.get_style("markdown.item.bullet", default="none")
395 bullet = Segment(" • ", bullet_style)
396 padding = Segment(" " * 3, bullet_style)
397 new_line = Segment("\n")
398 for first, line in loop_first(lines):
399 yield bullet if first else padding
400 yield from line
401 yield new_line
403 def render_number(
404 self, console: Console, options: ConsoleOptions, number: int, last_number: int
405 ) -> RenderResult:
406 number_width = len(str(last_number)) + 2
407 render_options = options.update(width=options.max_width - number_width)
408 lines = console.render_lines(self.elements, render_options, style=self.style)
409 number_style = console.get_style("markdown.item.number", default="none")
411 new_line = Segment("\n")
412 padding = Segment(" " * number_width, number_style)
413 numeral = Segment(f"{number}".rjust(number_width - 1) + " ", number_style)
414 for first, line in loop_first(lines):
415 yield numeral if first else padding
416 yield from line
417 yield new_line
420class Link(TextElement):
421 @classmethod
422 def create(cls, markdown: "Markdown", token: Token) -> "MarkdownElement":
423 url = token.attrs.get("href", "#")
424 return cls(token.content, str(url))
426 def __init__(self, text: str, href: str):
427 self.text = Text(text)
428 self.href = href
431class ImageItem(TextElement):
432 """Renders a placeholder for an image."""
434 new_line = False
436 @classmethod
437 def create(cls, markdown: "Markdown", token: Token) -> "MarkdownElement":
438 """Factory to create markdown element,
440 Args:
441 markdown (Markdown): The parent Markdown object.
442 token (Any): A token from markdown-it.
444 Returns:
445 MarkdownElement: A new markdown element
446 """
447 return cls(str(token.attrs.get("src", "")), markdown.hyperlinks)
449 def __init__(self, destination: str, hyperlinks: bool) -> None:
450 self.destination = destination
451 self.hyperlinks = hyperlinks
452 self.link: Optional[str] = None
453 super().__init__()
455 def on_enter(self, context: "MarkdownContext") -> None:
456 self.link = context.current_style.link
457 self.text = Text(justify="left")
458 super().on_enter(context)
460 def __rich_console__(
461 self, console: Console, options: ConsoleOptions
462 ) -> RenderResult:
463 link_style = Style(link=self.link or self.destination or None)
464 title = self.text or Text(self.destination.strip("/").rsplit("/", 1)[-1])
465 if self.hyperlinks:
466 title.stylize(link_style)
467 text = Text.assemble("🌆 ", title, " ", end="")
468 yield text
471class MarkdownContext:
472 """Manages the console render state."""
474 def __init__(
475 self,
476 console: Console,
477 options: ConsoleOptions,
478 style: Style,
479 inline_code_lexer: Optional[str] = None,
480 inline_code_theme: str = "monokai",
481 ) -> None:
482 self.console = console
483 self.options = options
484 self.style_stack: StyleStack = StyleStack(style)
485 self.stack: Stack[MarkdownElement] = Stack()
487 self._syntax: Optional[Syntax] = None
488 if inline_code_lexer is not None:
489 self._syntax = Syntax("", inline_code_lexer, theme=inline_code_theme)
491 @property
492 def current_style(self) -> Style:
493 """Current style which is the product of all styles on the stack."""
494 return self.style_stack.current
496 def on_text(self, text: str, node_type: str) -> None:
497 """Called when the parser visits text."""
498 if node_type in {"fence", "code_inline"} and self._syntax is not None:
499 highlight_text = self._syntax.highlight(text)
500 highlight_text.rstrip()
501 self.stack.top.on_text(
502 self, Text.assemble(highlight_text, style=self.style_stack.current)
503 )
504 else:
505 self.stack.top.on_text(self, text)
507 def enter_style(self, style_name: Union[str, Style]) -> Style:
508 """Enter a style context."""
509 style = self.console.get_style(style_name, default="none")
510 self.style_stack.push(style)
511 return self.current_style
513 def leave_style(self) -> Style:
514 """Leave a style context."""
515 style = self.style_stack.pop()
516 return style
519class Markdown(JupyterMixin):
520 """A Markdown renderable.
522 Args:
523 markup (str): A string containing markdown.
524 code_theme (str, optional): Pygments theme for code blocks. Defaults to "monokai".
525 justify (JustifyMethod, optional): Justify value for paragraphs. Defaults to None.
526 style (Union[str, Style], optional): Optional style to apply to markdown.
527 hyperlinks (bool, optional): Enable hyperlinks. Defaults to ``True``.
528 inline_code_lexer: (str, optional): Lexer to use if inline code highlighting is
529 enabled. Defaults to None.
530 inline_code_theme: (Optional[str], optional): Pygments theme for inline code
531 highlighting, or None for no highlighting. Defaults to None.
532 """
534 elements: ClassVar[Dict[str, Type[MarkdownElement]]] = {
535 "paragraph_open": Paragraph,
536 "heading_open": Heading,
537 "fence": CodeBlock,
538 "code_block": CodeBlock,
539 "blockquote_open": BlockQuote,
540 "hr": HorizontalRule,
541 "bullet_list_open": ListElement,
542 "ordered_list_open": ListElement,
543 "list_item_open": ListItem,
544 "image": ImageItem,
545 "table_open": TableElement,
546 "tbody_open": TableBodyElement,
547 "thead_open": TableHeaderElement,
548 "tr_open": TableRowElement,
549 "td_open": TableDataElement,
550 "th_open": TableDataElement,
551 }
553 inlines = {"em", "strong", "code", "s"}
555 def __init__(
556 self,
557 markup: str,
558 code_theme: str = "monokai",
559 justify: Optional[JustifyMethod] = None,
560 style: Union[str, Style] = "none",
561 hyperlinks: bool = True,
562 inline_code_lexer: Optional[str] = None,
563 inline_code_theme: Optional[str] = None,
564 ) -> None:
565 parser = MarkdownIt().enable("strikethrough").enable("table")
566 self.markup = markup
567 self.parsed = parser.parse(markup)
568 self.code_theme = code_theme
569 self.justify: Optional[JustifyMethod] = justify
570 self.style = style
571 self.hyperlinks = hyperlinks
572 self.inline_code_lexer = inline_code_lexer
573 self.inline_code_theme = inline_code_theme or code_theme
575 def _flatten_tokens(self, tokens: Iterable[Token]) -> Iterable[Token]:
576 """Flattens the token stream."""
577 for token in tokens:
578 is_fence = token.type == "fence"
579 is_image = token.tag == "img"
580 if token.children and not (is_image or is_fence):
581 yield from self._flatten_tokens(token.children)
582 else:
583 yield token
585 def __rich_console__(
586 self, console: Console, options: ConsoleOptions
587 ) -> RenderResult:
588 """Render markdown to the console."""
589 style = console.get_style(self.style, default="none")
590 options = options.update(height=None)
591 context = MarkdownContext(
592 console,
593 options,
594 style,
595 inline_code_lexer=self.inline_code_lexer,
596 inline_code_theme=self.inline_code_theme,
597 )
598 tokens = self.parsed
599 inline_style_tags = self.inlines
600 new_line = False
601 _new_line_segment = Segment.line()
603 for token in self._flatten_tokens(tokens):
604 node_type = token.type
605 tag = token.tag
607 entering = token.nesting == 1
608 exiting = token.nesting == -1
609 self_closing = token.nesting == 0
611 if node_type == "text":
612 context.on_text(token.content, node_type)
613 elif node_type == "hardbreak":
614 context.on_text("\n", node_type)
615 elif node_type == "softbreak":
616 context.on_text(" ", node_type)
617 elif node_type == "link_open":
618 href = str(token.attrs.get("href", ""))
619 if self.hyperlinks:
620 link_style = console.get_style("markdown.link_url", default="none")
621 link_style += Style(link=href)
622 context.enter_style(link_style)
623 else:
624 context.stack.push(Link.create(self, token))
625 elif node_type == "link_close":
626 if self.hyperlinks:
627 context.leave_style()
628 else:
629 element = context.stack.pop()
630 assert isinstance(element, Link)
631 link_style = console.get_style("markdown.link", default="none")
632 context.enter_style(link_style)
633 context.on_text(element.text.plain, node_type)
634 context.leave_style()
635 context.on_text(" (", node_type)
636 link_url_style = console.get_style(
637 "markdown.link_url", default="none"
638 )
639 context.enter_style(link_url_style)
640 context.on_text(element.href, node_type)
641 context.leave_style()
642 context.on_text(")", node_type)
643 elif (
644 tag in inline_style_tags
645 and node_type != "fence"
646 and node_type != "code_block"
647 ):
648 if entering:
649 # If it's an opening inline token e.g. strong, em, etc.
650 # Then we move into a style context i.e. push to stack.
651 context.enter_style(f"markdown.{tag}")
652 elif exiting:
653 # If it's a closing inline style, then we pop the style
654 # off of the stack, to move out of the context of it...
655 context.leave_style()
656 else:
657 # If it's a self-closing inline style e.g. `code_inline`
658 context.enter_style(f"markdown.{tag}")
659 if token.content:
660 context.on_text(token.content, node_type)
661 context.leave_style()
662 else:
663 # Map the markdown tag -> MarkdownElement renderable
664 element_class = self.elements.get(token.type) or UnknownElement
665 element = element_class.create(self, token)
667 if entering or self_closing:
668 context.stack.push(element)
669 element.on_enter(context)
671 if exiting: # CLOSING tag
672 element = context.stack.pop()
674 should_render = not context.stack or (
675 context.stack
676 and context.stack.top.on_child_close(context, element)
677 )
679 if should_render:
680 if new_line:
681 yield _new_line_segment
683 yield from console.render(element, context.options)
684 elif self_closing: # SELF-CLOSING tags (e.g. text, code, image)
685 context.stack.pop()
686 text = token.content
687 if text is not None:
688 element.on_text(context, text)
690 should_render = (
691 not context.stack
692 or context.stack
693 and context.stack.top.on_child_close(context, element)
694 )
695 if should_render:
696 if new_line:
697 yield _new_line_segment
698 yield from console.render(element, context.options)
700 if exiting or self_closing:
701 element.on_leave(context)
702 new_line = element.new_line
705if __name__ == "__main__": # pragma: no cover
706 import argparse
707 import sys
709 parser = argparse.ArgumentParser(
710 description="Render Markdown to the console with Rich"
711 )
712 parser.add_argument(
713 "path",
714 metavar="PATH",
715 help="path to markdown file, or - for stdin",
716 )
717 parser.add_argument(
718 "-c",
719 "--force-color",
720 dest="force_color",
721 action="store_true",
722 default=None,
723 help="force color for non-terminals",
724 )
725 parser.add_argument(
726 "-t",
727 "--code-theme",
728 dest="code_theme",
729 default="monokai",
730 help="pygments code theme",
731 )
732 parser.add_argument(
733 "-i",
734 "--inline-code-lexer",
735 dest="inline_code_lexer",
736 default=None,
737 help="inline_code_lexer",
738 )
739 parser.add_argument(
740 "-y",
741 "--hyperlinks",
742 dest="hyperlinks",
743 action="store_true",
744 help="enable hyperlinks",
745 )
746 parser.add_argument(
747 "-w",
748 "--width",
749 type=int,
750 dest="width",
751 default=None,
752 help="width of output (default will auto-detect)",
753 )
754 parser.add_argument(
755 "-j",
756 "--justify",
757 dest="justify",
758 action="store_true",
759 help="enable full text justify",
760 )
761 parser.add_argument(
762 "-p",
763 "--page",
764 dest="page",
765 action="store_true",
766 help="use pager to scroll output",
767 )
768 args = parser.parse_args()
770 from rich.console import Console
772 if args.path == "-":
773 markdown_body = sys.stdin.read()
774 else:
775 with open(args.path, "rt", encoding="utf-8") as markdown_file:
776 markdown_body = markdown_file.read()
778 markdown = Markdown(
779 markdown_body,
780 justify="full" if args.justify else "left",
781 code_theme=args.code_theme,
782 hyperlinks=args.hyperlinks,
783 inline_code_lexer=args.inline_code_lexer,
784 )
785 if args.page:
786 import io
787 import pydoc
789 fileio = io.StringIO()
790 console = Console(
791 file=fileio, force_terminal=args.force_color, width=args.width
792 )
793 console.print(markdown)
794 pydoc.pager(fileio.getvalue())
796 else:
797 console = Console(
798 force_terminal=args.force_color, width=args.width, record=True
799 )
800 console.print(markdown)