markup.py 7.9 KB

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