Spaces:
Running
Running
import re | |
from ast import literal_eval | |
from operator import attrgetter | |
from typing import Callable, Iterable, List, Match, NamedTuple, Optional, Tuple, Union | |
from ._emoji_replace import _emoji_replace | |
from .emoji import EmojiVariant | |
from .errors import MarkupError | |
from .style import Style | |
from .text import Span, Text | |
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}" | |
) | |
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: | |
# Handle open brace escapes, where the brace is not part of a tag. | |
plain_text = plain_text.replace("\\[", "[") | |
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 import print | |
from pip._vendor.rich.table import Table | |
grid = Table("Markup", "Result", padding=(0, 1)) | |
for markup in MARKUP: | |
grid.add_row(Text(markup), markup) | |
print(grid) | |