markup.py 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251
  1. import re
  2. from ast import literal_eval
  3. from operator import attrgetter
  4. from typing import Callable, Iterable, List, Match, NamedTuple, Optional, Tuple, Union
  5. from ._emoji_replace import _emoji_replace
  6. from .emoji import EmojiVariant
  7. from .errors import MarkupError
  8. from .style import Style
  9. from .text import Span, Text
  10. RE_TAGS = re.compile(
  11. r"""((\\*)\[([a-z#/@][^[]*?)])""",
  12. re.VERBOSE,
  13. )
  14. RE_HANDLER = re.compile(r"^([\w.]*?)(\(.*?\))?$")
  15. class Tag(NamedTuple):
  16. """A tag in console markup."""
  17. name: str
  18. """The tag name. e.g. 'bold'."""
  19. parameters: Optional[str]
  20. """Any additional parameters after the name."""
  21. def __str__(self) -> str:
  22. return (
  23. self.name if self.parameters is None else f"{self.name} {self.parameters}"
  24. )
  25. @property
  26. def markup(self) -> str:
  27. """Get the string representation of this tag."""
  28. return (
  29. f"[{self.name}]"
  30. if self.parameters is None
  31. else f"[{self.name}={self.parameters}]"
  32. )
  33. _ReStringMatch = Match[str] # regex match object
  34. _ReSubCallable = Callable[[_ReStringMatch], str] # Callable invoked by re.sub
  35. _EscapeSubMethod = Callable[[_ReSubCallable, str], str] # Sub method of a compiled re
  36. def escape(
  37. markup: str,
  38. _escape: _EscapeSubMethod = re.compile(r"(\\*)(\[[a-z#/@][^[]*?])").sub,
  39. ) -> str:
  40. """Escapes text so that it won't be interpreted as markup.
  41. Args:
  42. markup (str): Content to be inserted in to markup.
  43. Returns:
  44. str: Markup with square brackets escaped.
  45. """
  46. def escape_backslashes(match: Match[str]) -> str:
  47. """Called by re.sub replace matches."""
  48. backslashes, text = match.groups()
  49. return f"{backslashes}{backslashes}\\{text}"
  50. markup = _escape(escape_backslashes, markup)
  51. if markup.endswith("\\") and not markup.endswith("\\\\"):
  52. return markup + "\\"
  53. return markup
  54. def _parse(markup: str) -> Iterable[Tuple[int, Optional[str], Optional[Tag]]]:
  55. """Parse markup in to an iterable of tuples of (position, text, tag).
  56. Args:
  57. markup (str): A string containing console markup
  58. """
  59. position = 0
  60. _divmod = divmod
  61. _Tag = Tag
  62. for match in RE_TAGS.finditer(markup):
  63. full_text, escapes, tag_text = match.groups()
  64. start, end = match.span()
  65. if start > position:
  66. yield start, markup[position:start], None
  67. if escapes:
  68. backslashes, escaped = _divmod(len(escapes), 2)
  69. if backslashes:
  70. # Literal backslashes
  71. yield start, "\\" * backslashes, None
  72. start += backslashes * 2
  73. if escaped:
  74. # Escape of tag
  75. yield start, full_text[len(escapes) :], None
  76. position = end
  77. continue
  78. text, equals, parameters = tag_text.partition("=")
  79. yield start, None, _Tag(text, parameters if equals else None)
  80. position = end
  81. if position < len(markup):
  82. yield position, markup[position:], None
  83. def render(
  84. markup: str,
  85. style: Union[str, Style] = "",
  86. emoji: bool = True,
  87. emoji_variant: Optional[EmojiVariant] = None,
  88. ) -> Text:
  89. """Render console markup in to a Text instance.
  90. Args:
  91. markup (str): A string containing console markup.
  92. style: (Union[str, Style]): The style to use.
  93. emoji (bool, optional): Also render emoji code. Defaults to True.
  94. emoji_variant (str, optional): Optional emoji variant, either "text" or "emoji". Defaults to None.
  95. Raises:
  96. MarkupError: If there is a syntax error in the markup.
  97. Returns:
  98. Text: A test instance.
  99. """
  100. emoji_replace = _emoji_replace
  101. if "[" not in markup:
  102. return Text(
  103. emoji_replace(markup, default_variant=emoji_variant) if emoji else markup,
  104. style=style,
  105. )
  106. text = Text(style=style)
  107. append = text.append
  108. normalize = Style.normalize
  109. style_stack: List[Tuple[int, Tag]] = []
  110. pop = style_stack.pop
  111. spans: List[Span] = []
  112. append_span = spans.append
  113. _Span = Span
  114. _Tag = Tag
  115. def pop_style(style_name: str) -> Tuple[int, Tag]:
  116. """Pop tag matching given style name."""
  117. for index, (_, tag) in enumerate(reversed(style_stack), 1):
  118. if tag.name == style_name:
  119. return pop(-index)
  120. raise KeyError(style_name)
  121. for position, plain_text, tag in _parse(markup):
  122. if plain_text is not None:
  123. # Handle open brace escapes, where the brace is not part of a tag.
  124. plain_text = plain_text.replace("\\[", "[")
  125. append(emoji_replace(plain_text) if emoji else plain_text)
  126. elif tag is not None:
  127. if tag.name.startswith("/"): # Closing tag
  128. style_name = tag.name[1:].strip()
  129. if style_name: # explicit close
  130. style_name = normalize(style_name)
  131. try:
  132. start, open_tag = pop_style(style_name)
  133. except KeyError:
  134. raise MarkupError(
  135. f"closing tag '{tag.markup}' at position {position} doesn't match any open tag"
  136. ) from None
  137. else: # implicit close
  138. try:
  139. start, open_tag = pop()
  140. except IndexError:
  141. raise MarkupError(
  142. f"closing tag '[/]' at position {position} has nothing to close"
  143. ) from None
  144. if open_tag.name.startswith("@"):
  145. if open_tag.parameters:
  146. handler_name = ""
  147. parameters = open_tag.parameters.strip()
  148. handler_match = RE_HANDLER.match(parameters)
  149. if handler_match is not None:
  150. handler_name, match_parameters = handler_match.groups()
  151. parameters = (
  152. "()" if match_parameters is None else match_parameters
  153. )
  154. try:
  155. meta_params = literal_eval(parameters)
  156. except SyntaxError as error:
  157. raise MarkupError(
  158. f"error parsing {parameters!r} in {open_tag.parameters!r}; {error.msg}"
  159. )
  160. except Exception as error:
  161. raise MarkupError(
  162. f"error parsing {open_tag.parameters!r}; {error}"
  163. ) from None
  164. if handler_name:
  165. meta_params = (
  166. handler_name,
  167. meta_params
  168. if isinstance(meta_params, tuple)
  169. else (meta_params,),
  170. )
  171. else:
  172. meta_params = ()
  173. append_span(
  174. _Span(
  175. start, len(text), Style(meta={open_tag.name: meta_params})
  176. )
  177. )
  178. else:
  179. append_span(_Span(start, len(text), str(open_tag)))
  180. else: # Opening tag
  181. normalized_tag = _Tag(normalize(tag.name), tag.parameters)
  182. style_stack.append((len(text), normalized_tag))
  183. text_length = len(text)
  184. while style_stack:
  185. start, tag = style_stack.pop()
  186. style = str(tag)
  187. if style:
  188. append_span(_Span(start, text_length, style))
  189. text.spans = sorted(spans[::-1], key=attrgetter("start"))
  190. return text
  191. if __name__ == "__main__": # pragma: no cover
  192. MARKUP = [
  193. "[red]Hello World[/red]",
  194. "[magenta]Hello [b]World[/b]",
  195. "[bold]Bold[italic] bold and italic [/bold]italic[/italic]",
  196. "Click [link=https://www.willmcgugan.com]here[/link] to visit my Blog",
  197. ":warning-emoji: [bold red blink] DANGER![/]",
  198. ]
  199. from rich import print
  200. from rich.table import Table
  201. grid = Table("Markup", "Result", padding=(0, 1))
  202. for markup in MARKUP:
  203. grid.add_row(Text(markup), markup)
  204. print(grid)