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