Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.8/site-packages/rich/markup.py: 20%
117 statements
« prev ^ index » next coverage.py v7.3.1, created at 2023-09-18 06:13 +0000
« prev ^ index » next coverage.py v7.3.1, created at 2023-09-18 06:13 +0000
1import re
2from ast import literal_eval
3from operator import attrgetter
4from typing import Callable, Iterable, List, Match, NamedTuple, Optional, Tuple, Union
6from ._emoji_replace import _emoji_replace
7from .emoji import EmojiVariant
8from .errors import MarkupError
9from .style import Style
10from .text import Span, Text
12RE_TAGS = re.compile(
13 r"""((\\*)\[([a-z#/@][^[]*?)])""",
14 re.VERBOSE,
15)
17RE_HANDLER = re.compile(r"^([\w.]*?)(\(.*?\))?$")
20class Tag(NamedTuple):
21 """A tag in console markup."""
23 name: str
24 """The tag name. e.g. 'bold'."""
25 parameters: Optional[str]
26 """Any additional parameters after the name."""
28 def __str__(self) -> str:
29 return (
30 self.name if self.parameters is None else f"{self.name} {self.parameters}"
31 )
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 )
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
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.
54 Args:
55 markup (str): Content to be inserted in to markup.
57 Returns:
58 str: Markup with square brackets escaped.
59 """
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}"
66 markup = _escape(escape_backslashes, markup)
67 if markup.endswith("\\") and not markup.endswith("\\\\"):
68 return markup + "\\"
70 return markup
73def _parse(markup: str) -> Iterable[Tuple[int, Optional[str], Optional[Tag]]]:
74 """Parse markup in to an iterable of tuples of (position, text, tag).
76 Args:
77 markup (str): A string containing console markup
79 """
80 position = 0
81 _divmod = divmod
82 _Tag = Tag
83 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], None
88 if escapes:
89 backslashes, escaped = _divmod(len(escapes), 2)
90 if backslashes:
91 # Literal backslashes
92 yield start, "\\" * backslashes, None
93 start += backslashes * 2
94 if escaped:
95 # Escape of tag
96 yield start, full_text[len(escapes) :], None
97 position = end
98 continue
99 text, equals, parameters = tag_text.partition("=")
100 yield start, None, _Tag(text, parameters if equals else None)
101 position = end
102 if position < len(markup):
103 yield position, markup[position:], None
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.
114 Args:
115 markup (str): A string containing console markup.
116 emoji (bool, optional): Also render emoji code. Defaults to True.
118 Raises:
119 MarkupError: If there is a syntax error in the markup.
121 Returns:
122 Text: A test instance.
123 """
124 emoji_replace = _emoji_replace
125 if "[" not in markup:
126 return Text(
127 emoji_replace(markup, default_variant=emoji_variant) if emoji else markup,
128 style=style,
129 )
130 text = Text(style=style)
131 append = text.append
132 normalize = Style.normalize
134 style_stack: List[Tuple[int, Tag]] = []
135 pop = style_stack.pop
137 spans: List[Span] = []
138 append_span = spans.append
140 _Span = Span
141 _Tag = Tag
143 def pop_style(style_name: str) -> Tuple[int, Tag]:
144 """Pop tag matching given style name."""
145 for index, (_, tag) in enumerate(reversed(style_stack), 1):
146 if tag.name == style_name:
147 return pop(-index)
148 raise KeyError(style_name)
150 for position, plain_text, tag in _parse(markup):
151 if plain_text is not None:
152 # Handle open brace escapes, where the brace is not part of a tag.
153 plain_text = plain_text.replace("\\[", "[")
154 append(emoji_replace(plain_text) if emoji else plain_text)
155 elif tag is not None:
156 if tag.name.startswith("/"): # Closing tag
157 style_name = tag.name[1:].strip()
159 if style_name: # explicit close
160 style_name = normalize(style_name)
161 try:
162 start, open_tag = pop_style(style_name)
163 except KeyError:
164 raise MarkupError(
165 f"closing tag '{tag.markup}' at position {position} doesn't match any open tag"
166 ) from None
167 else: # implicit close
168 try:
169 start, open_tag = pop()
170 except IndexError:
171 raise MarkupError(
172 f"closing tag '[/]' at position {position} has nothing to close"
173 ) from None
175 if open_tag.name.startswith("@"):
176 if open_tag.parameters:
177 handler_name = ""
178 parameters = open_tag.parameters.strip()
179 handler_match = RE_HANDLER.match(parameters)
180 if handler_match is not None:
181 handler_name, match_parameters = handler_match.groups()
182 parameters = (
183 "()" if match_parameters is None else match_parameters
184 )
186 try:
187 meta_params = literal_eval(parameters)
188 except SyntaxError as error:
189 raise MarkupError(
190 f"error parsing {parameters!r} in {open_tag.parameters!r}; {error.msg}"
191 )
192 except Exception as error:
193 raise MarkupError(
194 f"error parsing {open_tag.parameters!r}; {error}"
195 ) from None
197 if handler_name:
198 meta_params = (
199 handler_name,
200 meta_params
201 if isinstance(meta_params, tuple)
202 else (meta_params,),
203 )
205 else:
206 meta_params = ()
208 append_span(
209 _Span(
210 start, len(text), Style(meta={open_tag.name: meta_params})
211 )
212 )
213 else:
214 append_span(_Span(start, len(text), str(open_tag)))
216 else: # Opening tag
217 normalized_tag = _Tag(normalize(tag.name), tag.parameters)
218 style_stack.append((len(text), normalized_tag))
220 text_length = len(text)
221 while style_stack:
222 start, tag = style_stack.pop()
223 style = str(tag)
224 if style:
225 append_span(_Span(start, text_length, style))
227 text.spans = sorted(spans[::-1], key=attrgetter("start"))
228 return text
231if __name__ == "__main__": # pragma: no cover
232 MARKUP = [
233 "[red]Hello World[/red]",
234 "[magenta]Hello [b]World[/b]",
235 "[bold]Bold[italic] bold and italic [/bold]italic[/italic]",
236 "Click [link=https://www.willmcgugan.com]here[/link] to visit my Blog",
237 ":warning-emoji: [bold red blink] DANGER![/]",
238 ]
240 from rich import print
241 from rich.table import Table
243 grid = Table("Markup", "Result", padding=(0, 1))
245 for markup in MARKUP:
246 grid.add_row(Text(markup), markup)
248 print(grid)