123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244 |
- from ast import literal_eval
- from operator import attrgetter
- import re
- from typing import Callable, Iterable, List, Match, NamedTuple, Optional, Tuple, Union
- from .errors import MarkupError
- from .style import Style
- from .text import Span, Text
- from .emoji import EmojiVariant
- from ._emoji_replace import _emoji_replace
- RE_TAGS = re.compile(
- r"""((\\*)\[([a-z#\/@].*?)\])""",
- re.VERBOSE,
- )
- RE_HANDLER = re.compile(r"^([\w\.]*?)(\(.*?\))?$")
- class Tag(NamedTuple):
- """A tag in console markup."""
- name: str
- """The tag name. e.g. 'bold'."""
- parameters: Optional[str]
- """Any additional parameters after the name."""
- def __str__(self) -> str:
- return (
- self.name if self.parameters is None else f"{self.name} {self.parameters}"
- )
- @property
- def markup(self) -> str:
- """Get the string representation of this tag."""
- return (
- f"[{self.name}]"
- if self.parameters is None
- else f"[{self.name}={self.parameters}]"
- )
- _ReStringMatch = Match[str] # regex match object
- _ReSubCallable = Callable[[_ReStringMatch], str] # Callable invoked by re.sub
- _EscapeSubMethod = Callable[[_ReSubCallable, str], str] # Sub method of a compiled re
- def escape(
- markup: str, _escape: _EscapeSubMethod = re.compile(r"(\\*)(\[[a-z#\/@].*?\])").sub
- ) -> str:
- """Escapes text so that it won't be interpreted as markup.
- Args:
- markup (str): Content to be inserted in to markup.
- Returns:
- str: Markup with square brackets escaped.
- """
- def escape_backslashes(match: Match[str]) -> str:
- """Called by re.sub replace matches."""
- backslashes, text = match.groups()
- return f"{backslashes}{backslashes}\\{text}"
- markup = _escape(escape_backslashes, markup)
- return markup
- def _parse(markup: str) -> Iterable[Tuple[int, Optional[str], Optional[Tag]]]:
- """Parse markup in to an iterable of tuples of (position, text, tag).
- Args:
- markup (str): A string containing console markup
- """
- position = 0
- _divmod = divmod
- _Tag = Tag
- for match in RE_TAGS.finditer(markup):
- full_text, escapes, tag_text = match.groups()
- start, end = match.span()
- if start > position:
- yield start, markup[position:start], None
- if escapes:
- backslashes, escaped = _divmod(len(escapes), 2)
- if backslashes:
- # Literal backslashes
- yield start, "\\" * backslashes, None
- start += backslashes * 2
- if escaped:
- # Escape of tag
- yield start, full_text[len(escapes) :], None
- position = end
- continue
- text, equals, parameters = tag_text.partition("=")
- yield start, None, _Tag(text, parameters if equals else None)
- position = end
- if position < len(markup):
- yield position, markup[position:], None
- def render(
- markup: str,
- style: Union[str, Style] = "",
- emoji: bool = True,
- emoji_variant: Optional[EmojiVariant] = None,
- ) -> Text:
- """Render console markup in to a Text instance.
- Args:
- markup (str): A string containing console markup.
- emoji (bool, optional): Also render emoji code. Defaults to True.
- Raises:
- MarkupError: If there is a syntax error in the markup.
- Returns:
- Text: A test instance.
- """
- emoji_replace = _emoji_replace
- if "[" not in markup:
- return Text(
- emoji_replace(markup, default_variant=emoji_variant) if emoji else markup,
- style=style,
- )
- text = Text(style=style)
- append = text.append
- normalize = Style.normalize
- style_stack: List[Tuple[int, Tag]] = []
- pop = style_stack.pop
- spans: List[Span] = []
- append_span = spans.append
- _Span = Span
- _Tag = Tag
- def pop_style(style_name: str) -> Tuple[int, Tag]:
- """Pop tag matching given style name."""
- for index, (_, tag) in enumerate(reversed(style_stack), 1):
- if tag.name == style_name:
- return pop(-index)
- raise KeyError(style_name)
- for position, plain_text, tag in _parse(markup):
- if plain_text is not None:
- append(emoji_replace(plain_text) if emoji else plain_text)
- elif tag is not None:
- if tag.name.startswith("/"): # Closing tag
- style_name = tag.name[1:].strip()
- if style_name: # explicit close
- style_name = normalize(style_name)
- try:
- start, open_tag = pop_style(style_name)
- except KeyError:
- raise MarkupError(
- f"closing tag '{tag.markup}' at position {position} doesn't match any open tag"
- ) from None
- else: # implicit close
- try:
- start, open_tag = pop()
- except IndexError:
- raise MarkupError(
- f"closing tag '[/]' at position {position} has nothing to close"
- ) from None
- if open_tag.name.startswith("@"):
- if open_tag.parameters:
- handler_name = ""
- parameters = open_tag.parameters.strip()
- handler_match = RE_HANDLER.match(parameters)
- if handler_match is not None:
- handler_name, match_parameters = handler_match.groups()
- parameters = (
- "()" if match_parameters is None else match_parameters
- )
- try:
- meta_params = literal_eval(parameters)
- except SyntaxError as error:
- raise MarkupError(
- f"error parsing {parameters!r} in {open_tag.parameters!r}; {error.msg}"
- )
- except Exception as error:
- raise MarkupError(
- f"error parsing {open_tag.parameters!r}; {error}"
- ) from None
- if handler_name:
- meta_params = (
- handler_name,
- meta_params
- if isinstance(meta_params, tuple)
- else (meta_params,),
- )
- else:
- meta_params = ()
- append_span(
- _Span(
- start, len(text), Style(meta={open_tag.name: meta_params})
- )
- )
- else:
- append_span(_Span(start, len(text), str(open_tag)))
- else: # Opening tag
- normalized_tag = _Tag(normalize(tag.name), tag.parameters)
- style_stack.append((len(text), normalized_tag))
- text_length = len(text)
- while style_stack:
- start, tag = style_stack.pop()
- style = str(tag)
- if style:
- append_span(_Span(start, text_length, style))
- text.spans = sorted(spans[::-1], key=attrgetter("start"))
- return text
- if __name__ == "__main__": # pragma: no cover
- MARKUP = [
- "[red]Hello World[/red]",
- "[magenta]Hello [b]World[/b]",
- "[bold]Bold[italic] bold and italic [/bold]italic[/italic]",
- "Click [link=https://www.willmcgugan.com]here[/link] to visit my Blog",
- ":warning-emoji: [bold red blink] DANGER![/]",
- ]
- from pip._vendor.rich.table import Table
- from pip._vendor.rich import print
- grid = Table("Markup", "Result", padding=(0, 1))
- for markup in MARKUP:
- grid.add_row(Text(markup), markup)
- print(grid)
|