codekingpro/portable-devtools
114k
1import re2from ast import literal_eval3from operator import attrgetter4from typing import Callable, Iterable, List, Match, NamedTuple, Optional, Tuple, Union5 6from ._emoji_replace import _emoji_replace7from .emoji import EmojiVariant8from .errors import MarkupError9from .style import Style10from .text import Span, Text11 12RE_TAGS = re.compile(13 r"""((\\*)\[([a-z#/@][^[]*?)])""",14 re.VERBOSE,15)16 17RE_HANDLER = re.compile(r"^([\w.]*?)(\(.*?\))?$")18 19 20class Tag(NamedTuple):21 """A tag in console markup."""22 23 name: str24 """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 @property34 def markup(self) -> str:35 """Get the string representation of this tag."""36 return (37 f"[{self.name}]"38 if self.parameters is None39 else f"[{self.name}={self.parameters}]"40 )41 42 43_ReStringMatch = Match[str] # regex match object44_ReSubCallable = Callable[[_ReStringMatch], str] # Callable invoked by re.sub45_EscapeSubMethod = Callable[[_ReSubCallable, str], str] # Sub method of a compiled re46 47 48def 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 if markup.endswith("\\") and not markup.endswith("\\\\"):68 return markup + "\\"69 70 return markup71 72 73def _parse(markup: str) -> Iterable[Tuple[int, Optional[str], Optional[Tag]]]:74 """Parse markup in to an iterable of tuples of (position, text, tag).75 76 Args:77 markup (str): A string containing console markup78 79 """80 position = 081 _divmod = divmod82 _Tag = Tag83 for match in RE_TAGS.finditer(markup):84 full_text, escapes, tag_text = match.groups()85 start, end = match.span()86 if start > position:87 yield start, markup[position:start], None88 if escapes:89 backslashes, escaped = _divmod(len(escapes), 2)90 if backslashes:91 # Literal backslashes92 yield start, "\\" * backslashes, None93 start += backslashes * 294 if escaped:95 # Escape of tag96 yield start, full_text[len(escapes) :], None97 position = end98 continue99 text, equals, parameters = tag_text.partition("=")100 yield start, None, _Tag(text, parameters if equals else None)101 position = end102 if position < len(markup):103 yield position, markup[position:], None104 105 106def render(107 markup: str,108 style: Union[str, Style] = "",109 emoji: bool = True,110 emoji_variant: Optional[EmojiVariant] = None,111) -> Text:112 """Render console markup in to a Text instance.113 114 Args:115 markup (str): A string containing console markup.116 style: (Union[str, Style]): The style to use.117 emoji (bool, optional): Also render emoji code. Defaults to True.118 emoji_variant (str, optional): Optional emoji variant, either "text" or "emoji". Defaults to None.119 120 121 Raises:122 MarkupError: If there is a syntax error in the markup.123 124 Returns:125 Text: A test instance.126 """127 emoji_replace = _emoji_replace128 if "[" not in markup:129 return Text(130 emoji_replace(markup, default_variant=emoji_variant) if emoji else markup,131 style=style,132 )133 text = Text(style=style)134 append = text.append135 normalize = Style.normalize136 137 style_stack: List[Tuple[int, Tag]] = []138 pop = style_stack.pop139 140 spans: List[Span] = []141 append_span = spans.append142 143 _Span = Span144 _Tag = Tag145 146 def pop_style(style_name: str) -> Tuple[int, Tag]:147 """Pop tag matching given style name."""148 for index, (_, tag) in enumerate(reversed(style_stack), 1):149 if tag.name == style_name:150 return pop(-index)151 raise KeyError(style_name)152 153 for position, plain_text, tag in _parse(markup):154 if plain_text is not None:155 # Handle open brace escapes, where the brace is not part of a tag.156 plain_text = plain_text.replace("\\[", "[")157 append(emoji_replace(plain_text) if emoji else plain_text)158 elif tag is not None:159 if tag.name.startswith("/"): # Closing tag160 style_name = tag.name[1:].strip()161 162 if style_name: # explicit close163 style_name = normalize(style_name)164 try:165 start, open_tag = pop_style(style_name)166 except KeyError:167 raise MarkupError(168 f"closing tag '{tag.markup}' at position {position} doesn't match any open tag"169 ) from None170 else: # implicit close171 try:172 start, open_tag = pop()173 except IndexError:174 raise MarkupError(175 f"closing tag '[/]' at position {position} has nothing to close"176 ) from None177 178 if open_tag.name.startswith("@"):179 if open_tag.parameters:180 handler_name = ""181 parameters = open_tag.parameters.strip()182 handler_match = RE_HANDLER.match(parameters)183 if handler_match is not None:184 handler_name, match_parameters = handler_match.groups()185 parameters = (186 "()" if match_parameters is None else match_parameters187 )188 189 try:190 meta_params = literal_eval(parameters)191 except SyntaxError as error:192 raise MarkupError(193 f"error parsing {parameters!r} in {open_tag.parameters!r}; {error.msg}"194 )195 except Exception as error:196 raise MarkupError(197 f"error parsing {open_tag.parameters!r}; {error}"198 ) from None199 200 if handler_name:201 meta_params = (202 handler_name,203 meta_params204 if isinstance(meta_params, tuple)205 else (meta_params,),206 )207 208 else:209 meta_params = ()210 211 append_span(212 _Span(213 start, len(text), Style(meta={open_tag.name: meta_params})214 )215 )216 else:217 append_span(_Span(start, len(text), str(open_tag)))218 219 else: # Opening tag220 normalized_tag = _Tag(normalize(tag.name), tag.parameters)221 style_stack.append((len(text), normalized_tag))222 223 text_length = len(text)224 while style_stack:225 start, tag = style_stack.pop()226 style = str(tag)227 if style:228 append_span(_Span(start, text_length, style))229 230 text.spans = sorted(spans[::-1], key=attrgetter("start"))231 return text232 233 234if __name__ == "__main__": # pragma: no cover235 MARKUP = [236 "[red]Hello World[/red]",237 "[magenta]Hello [b]World[/b]",238 "[bold]Bold[italic] bold and italic [/bold]italic[/italic]",239 "Click [link=https://www.willmcgugan.com]here[/link] to visit my Blog",240 ":warning-emoji: [bold red blink] DANGER![/]",241 ]242 243 from rich import print244 from rich.table import Table245 246 grid = Table("Markup", "Result", padding=(0, 1))247 248 for markup in MARKUP:249 grid.add_row(Text(markup), markup)250 251 print(grid)252 