Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.8/site-packages/rich/markdown.py: 94%
371 statements
« prev ^ index » next coverage.py v7.2.7, created at 2023-06-07 06:07 +0000
« prev ^ index » next coverage.py v7.2.7, created at 2023-06-07 06:07 +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 "default", 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 assert self.header is not None
258 assert self.header.row is not None
259 for column in self.header.row.cells:
260 table.add_column(column.content)
262 assert self.body is not None
263 for row in self.body.rows:
264 row_content = [element.content for element in row.cells]
265 table.add_row(*row_content)
267 yield table
270class TableHeaderElement(MarkdownElement):
271 """MarkdownElement corresponding to `thead_open` and `thead_close`."""
273 def __init__(self) -> None:
274 self.row: TableRowElement | None = None
276 def on_child_close(
277 self, context: "MarkdownContext", child: "MarkdownElement"
278 ) -> 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(
291 self, context: "MarkdownContext", child: "MarkdownElement"
292 ) -> bool:
293 assert isinstance(child, TableRowElement)
294 self.rows.append(child)
295 return False
298class TableRowElement(MarkdownElement):
299 """MarkdownElement corresponding to `tr_open` and `tr_close`."""
301 def __init__(self) -> None:
302 self.cells: List[TableDataElement] = []
304 def on_child_close(
305 self, context: "MarkdownContext", child: "MarkdownElement"
306 ) -> bool:
307 assert isinstance(child, TableDataElement)
308 self.cells.append(child)
309 return False
312class TableDataElement(MarkdownElement):
313 """MarkdownElement corresponding to `td_open` and `td_close`
314 and `th_open` and `th_close`."""
316 @classmethod
317 def create(cls, markdown: "Markdown", token: Token) -> "MarkdownElement":
318 style = str(token.attrs.get("style" "")) or ""
320 justify: JustifyMethod
321 if "text-align:right" in style:
322 justify = "right"
323 elif "text-align:center" in style:
324 justify = "center"
325 elif "text-align:left" in style:
326 justify = "left"
327 else:
328 justify = "default"
330 assert justify in get_args(JustifyMethod)
331 return cls(justify=justify)
333 def __init__(self, justify: JustifyMethod) -> None:
334 self.content: TextType = ""
335 self.justify = justify
337 def on_text(self, context: "MarkdownContext", text: TextType) -> None:
338 plain = text.plain if isinstance(text, Text) else text
339 style = text.style if isinstance(text, Text) else ""
340 self.content = Text(
341 plain, justify=self.justify, style=context.style_stack.current
342 )
345class ListElement(MarkdownElement):
346 """A list element."""
348 @classmethod
349 def create(cls, markdown: "Markdown", token: Token) -> "ListElement":
350 return cls(token.type, int(token.attrs.get("start", 1)))
352 def __init__(self, list_type: str, list_start: int | None) -> None:
353 self.items: List[ListItem] = []
354 self.list_type = list_type
355 self.list_start = list_start
357 def on_child_close(
358 self, context: "MarkdownContext", child: "MarkdownElement"
359 ) -> bool:
360 assert isinstance(child, ListItem)
361 self.items.append(child)
362 return False
364 def __rich_console__(
365 self, console: Console, options: ConsoleOptions
366 ) -> RenderResult:
367 if self.list_type == "bullet_list_open":
368 for item in self.items:
369 yield from item.render_bullet(console, options)
370 else:
371 number = 1 if self.list_start is None else self.list_start
372 last_number = number + len(self.items)
373 for index, item in enumerate(self.items):
374 yield from item.render_number(
375 console, options, number + index, last_number
376 )
379class ListItem(TextElement):
380 """An item in a list."""
382 style_name = "markdown.item"
384 def __init__(self) -> None:
385 self.elements: Renderables = Renderables()
387 def on_child_close(
388 self, context: "MarkdownContext", child: "MarkdownElement"
389 ) -> bool:
390 self.elements.append(child)
391 return False
393 def render_bullet(self, console: Console, options: ConsoleOptions) -> RenderResult:
394 render_options = options.update(width=options.max_width - 3)
395 lines = console.render_lines(self.elements, render_options, style=self.style)
396 bullet_style = console.get_style("markdown.item.bullet", default="none")
398 bullet = Segment(" • ", bullet_style)
399 padding = Segment(" " * 3, bullet_style)
400 new_line = Segment("\n")
401 for first, line in loop_first(lines):
402 yield bullet if first else padding
403 yield from line
404 yield new_line
406 def render_number(
407 self, console: Console, options: ConsoleOptions, number: int, last_number: int
408 ) -> RenderResult:
409 number_width = len(str(last_number)) + 2
410 render_options = options.update(width=options.max_width - number_width)
411 lines = console.render_lines(self.elements, render_options, style=self.style)
412 number_style = console.get_style("markdown.item.number", default="none")
414 new_line = Segment("\n")
415 padding = Segment(" " * number_width, number_style)
416 numeral = Segment(f"{number}".rjust(number_width - 1) + " ", number_style)
417 for first, line in loop_first(lines):
418 yield numeral if first else padding
419 yield from line
420 yield new_line
423class Link(TextElement):
424 @classmethod
425 def create(cls, markdown: "Markdown", token: Token) -> "MarkdownElement":
426 url = token.attrs.get("href", "#")
427 return cls(token.content, str(url))
429 def __init__(self, text: str, href: str):
430 self.text = Text(text)
431 self.href = href
434class ImageItem(TextElement):
435 """Renders a placeholder for an image."""
437 new_line = False
439 @classmethod
440 def create(cls, markdown: "Markdown", token: Token) -> "MarkdownElement":
441 """Factory to create markdown element,
443 Args:
444 markdown (Markdown): The parent Markdown object.
445 token (Any): A token from markdown-it.
447 Returns:
448 MarkdownElement: A new markdown element
449 """
450 return cls(str(token.attrs.get("src", "")), markdown.hyperlinks)
452 def __init__(self, destination: str, hyperlinks: bool) -> None:
453 self.destination = destination
454 self.hyperlinks = hyperlinks
455 self.link: Optional[str] = None
456 super().__init__()
458 def on_enter(self, context: "MarkdownContext") -> None:
459 self.link = context.current_style.link
460 self.text = Text(justify="left")
461 super().on_enter(context)
463 def __rich_console__(
464 self, console: Console, options: ConsoleOptions
465 ) -> RenderResult:
466 link_style = Style(link=self.link or self.destination or None)
467 title = self.text or Text(self.destination.strip("/").rsplit("/", 1)[-1])
468 if self.hyperlinks:
469 title.stylize(link_style)
470 text = Text.assemble("🌆 ", title, " ", end="")
471 yield text
474class MarkdownContext:
475 """Manages the console render state."""
477 def __init__(
478 self,
479 console: Console,
480 options: ConsoleOptions,
481 style: Style,
482 inline_code_lexer: Optional[str] = None,
483 inline_code_theme: str = "monokai",
484 ) -> None:
485 self.console = console
486 self.options = options
487 self.style_stack: StyleStack = StyleStack(style)
488 self.stack: Stack[MarkdownElement] = Stack()
490 self._syntax: Optional[Syntax] = None
491 if inline_code_lexer is not None:
492 self._syntax = Syntax("", inline_code_lexer, theme=inline_code_theme)
494 @property
495 def current_style(self) -> Style:
496 """Current style which is the product of all styles on the stack."""
497 return self.style_stack.current
499 def on_text(self, text: str, node_type: str) -> None:
500 """Called when the parser visits text."""
501 if node_type in {"fence", "code_inline"} and self._syntax is not None:
502 highlight_text = self._syntax.highlight(text)
503 highlight_text.rstrip()
504 self.stack.top.on_text(
505 self, Text.assemble(highlight_text, style=self.style_stack.current)
506 )
507 else:
508 self.stack.top.on_text(self, text)
510 def enter_style(self, style_name: Union[str, Style]) -> Style:
511 """Enter a style context."""
512 style = self.console.get_style(style_name, default="none")
513 self.style_stack.push(style)
514 return self.current_style
516 def leave_style(self) -> Style:
517 """Leave a style context."""
518 style = self.style_stack.pop()
519 return style
522class Markdown(JupyterMixin):
523 """A Markdown renderable.
525 Args:
526 markup (str): A string containing markdown.
527 code_theme (str, optional): Pygments theme for code blocks. Defaults to "monokai".
528 justify (JustifyMethod, optional): Justify value for paragraphs. Defaults to None.
529 style (Union[str, Style], optional): Optional style to apply to markdown.
530 hyperlinks (bool, optional): Enable hyperlinks. Defaults to ``True``.
531 inline_code_lexer: (str, optional): Lexer to use if inline code highlighting is
532 enabled. Defaults to None.
533 inline_code_theme: (Optional[str], optional): Pygments theme for inline code
534 highlighting, or None for no highlighting. Defaults to None.
535 """
537 elements: ClassVar[Dict[str, Type[MarkdownElement]]] = {
538 "paragraph_open": Paragraph,
539 "heading_open": Heading,
540 "fence": CodeBlock,
541 "code_block": CodeBlock,
542 "blockquote_open": BlockQuote,
543 "hr": HorizontalRule,
544 "bullet_list_open": ListElement,
545 "ordered_list_open": ListElement,
546 "list_item_open": ListItem,
547 "image": ImageItem,
548 "table_open": TableElement,
549 "tbody_open": TableBodyElement,
550 "thead_open": TableHeaderElement,
551 "tr_open": TableRowElement,
552 "td_open": TableDataElement,
553 "th_open": TableDataElement,
554 }
556 inlines = {"em", "strong", "code", "s"}
558 def __init__(
559 self,
560 markup: str,
561 code_theme: str = "monokai",
562 justify: Optional[JustifyMethod] = None,
563 style: Union[str, Style] = "none",
564 hyperlinks: bool = True,
565 inline_code_lexer: Optional[str] = None,
566 inline_code_theme: Optional[str] = None,
567 ) -> None:
568 parser = MarkdownIt().enable("strikethrough").enable("table")
569 self.markup = markup
570 self.parsed = parser.parse(markup)
571 self.code_theme = code_theme
572 self.justify: Optional[JustifyMethod] = justify
573 self.style = style
574 self.hyperlinks = hyperlinks
575 self.inline_code_lexer = inline_code_lexer
576 self.inline_code_theme = inline_code_theme or code_theme
578 def _flatten_tokens(self, tokens: Iterable[Token]) -> Iterable[Token]:
579 """Flattens the token stream."""
580 for token in tokens:
581 is_fence = token.type == "fence"
582 is_image = token.tag == "img"
583 if token.children and not (is_image or is_fence):
584 yield from self._flatten_tokens(token.children)
585 else:
586 yield token
588 def __rich_console__(
589 self, console: Console, options: ConsoleOptions
590 ) -> RenderResult:
591 """Render markdown to the console."""
592 style = console.get_style(self.style, default="none")
593 options = options.update(height=None)
594 context = MarkdownContext(
595 console,
596 options,
597 style,
598 inline_code_lexer=self.inline_code_lexer,
599 inline_code_theme=self.inline_code_theme,
600 )
601 tokens = self.parsed
602 inline_style_tags = self.inlines
603 new_line = False
604 _new_line_segment = Segment.line()
606 for token in self._flatten_tokens(tokens):
607 node_type = token.type
608 tag = token.tag
610 entering = token.nesting == 1
611 exiting = token.nesting == -1
612 self_closing = token.nesting == 0
614 if node_type == "text":
615 context.on_text(token.content, node_type)
616 elif node_type == "hardbreak":
617 context.on_text("\n", node_type)
618 elif node_type == "softbreak":
619 context.on_text(" ", node_type)
620 elif node_type == "link_open":
621 href = str(token.attrs.get("href", ""))
622 if self.hyperlinks:
623 link_style = console.get_style("markdown.link_url", default="none")
624 link_style += Style(link=href)
625 context.enter_style(link_style)
626 else:
627 context.stack.push(Link.create(self, token))
628 elif node_type == "link_close":
629 if self.hyperlinks:
630 context.leave_style()
631 else:
632 element = context.stack.pop()
633 assert isinstance(element, Link)
634 link_style = console.get_style("markdown.link", default="none")
635 context.enter_style(link_style)
636 context.on_text(element.text.plain, node_type)
637 context.leave_style()
638 context.on_text(" (", node_type)
639 link_url_style = console.get_style(
640 "markdown.link_url", default="none"
641 )
642 context.enter_style(link_url_style)
643 context.on_text(element.href, node_type)
644 context.leave_style()
645 context.on_text(")", node_type)
646 elif (
647 tag in inline_style_tags
648 and node_type != "fence"
649 and node_type != "code_block"
650 ):
651 if entering:
652 # If it's an opening inline token e.g. strong, em, etc.
653 # Then we move into a style context i.e. push to stack.
654 context.enter_style(f"markdown.{tag}")
655 elif exiting:
656 # If it's a closing inline style, then we pop the style
657 # off of the stack, to move out of the context of it...
658 context.leave_style()
659 else:
660 # If it's a self-closing inline style e.g. `code_inline`
661 context.enter_style(f"markdown.{tag}")
662 if token.content:
663 context.on_text(token.content, node_type)
664 context.leave_style()
665 else:
666 # Map the markdown tag -> MarkdownElement renderable
667 element_class = self.elements.get(token.type) or UnknownElement
668 element = element_class.create(self, token)
670 if entering or self_closing:
671 context.stack.push(element)
672 element.on_enter(context)
674 if exiting: # CLOSING tag
675 element = context.stack.pop()
677 should_render = not context.stack or (
678 context.stack
679 and context.stack.top.on_child_close(context, element)
680 )
682 if should_render:
683 if new_line:
684 yield _new_line_segment
686 yield from console.render(element, context.options)
687 elif self_closing: # SELF-CLOSING tags (e.g. text, code, image)
688 context.stack.pop()
689 text = token.content
690 if text is not None:
691 element.on_text(context, text)
693 should_render = (
694 not context.stack
695 or context.stack
696 and context.stack.top.on_child_close(context, element)
697 )
698 if should_render:
699 if new_line:
700 yield _new_line_segment
701 yield from console.render(element, context.options)
703 if exiting or self_closing:
704 element.on_leave(context)
705 new_line = element.new_line
708if __name__ == "__main__": # pragma: no cover
709 import argparse
710 import sys
712 parser = argparse.ArgumentParser(
713 description="Render Markdown to the console with Rich"
714 )
715 parser.add_argument(
716 "path",
717 metavar="PATH",
718 help="path to markdown file, or - for stdin",
719 )
720 parser.add_argument(
721 "-c",
722 "--force-color",
723 dest="force_color",
724 action="store_true",
725 default=None,
726 help="force color for non-terminals",
727 )
728 parser.add_argument(
729 "-t",
730 "--code-theme",
731 dest="code_theme",
732 default="monokai",
733 help="pygments code theme",
734 )
735 parser.add_argument(
736 "-i",
737 "--inline-code-lexer",
738 dest="inline_code_lexer",
739 default=None,
740 help="inline_code_lexer",
741 )
742 parser.add_argument(
743 "-y",
744 "--hyperlinks",
745 dest="hyperlinks",
746 action="store_true",
747 help="enable hyperlinks",
748 )
749 parser.add_argument(
750 "-w",
751 "--width",
752 type=int,
753 dest="width",
754 default=None,
755 help="width of output (default will auto-detect)",
756 )
757 parser.add_argument(
758 "-j",
759 "--justify",
760 dest="justify",
761 action="store_true",
762 help="enable full text justify",
763 )
764 parser.add_argument(
765 "-p",
766 "--page",
767 dest="page",
768 action="store_true",
769 help="use pager to scroll output",
770 )
771 args = parser.parse_args()
773 from rich.console import Console
775 if args.path == "-":
776 markdown_body = sys.stdin.read()
777 else:
778 with open(args.path, "rt", encoding="utf-8") as markdown_file:
779 markdown_body = markdown_file.read()
781 markdown = Markdown(
782 markdown_body,
783 justify="full" if args.justify else "left",
784 code_theme=args.code_theme,
785 hyperlinks=args.hyperlinks,
786 inline_code_lexer=args.inline_code_lexer,
787 )
788 if args.page:
789 import io
790 import pydoc
792 fileio = io.StringIO()
793 console = Console(
794 file=fileio, force_terminal=args.force_color, width=args.width
795 )
796 console.print(markdown)
797 pydoc.pager(fileio.getvalue())
799 else:
800 console = Console(
801 force_terminal=args.force_color, width=args.width, record=True
802 )
803 console.print(markdown)