Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.8/site-packages/rich/markup.py: 64%
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
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 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.
121 Raises:
122 MarkupError: If there is a syntax error in the markup.
124 Returns:
125 Text: A test instance.
126 """
127 emoji_replace = _emoji_replace
128 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.append
135 normalize = Style.normalize
137 style_stack: List[Tuple[int, Tag]] = []
138 pop = style_stack.pop
140 spans: List[Span] = []
141 append_span = spans.append
143 _Span = Span
144 _Tag = Tag
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)
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 tag
160 style_name = tag.name[1:].strip()
162 if style_name: # explicit close
163 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 None
170 else: # implicit close
171 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 None
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_parameters
187 )
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 None
200 if handler_name:
201 meta_params = (
202 handler_name,
203 meta_params
204 if isinstance(meta_params, tuple)
205 else (meta_params,),
206 )
208 else:
209 meta_params = ()
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)))
219 else: # Opening tag
220 normalized_tag = _Tag(normalize(tag.name), tag.parameters)
221 style_stack.append((len(text), normalized_tag))
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))
230 text.spans = sorted(spans[::-1], key=attrgetter("start"))
231 return text
234if __name__ == "__main__": # pragma: no cover
235 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 ]
243 from rich import print
244 from rich.table import Table
246 grid = Table("Markup", "Result", padding=(0, 1))
248 for markup in MARKUP:
249 grid.add_row(Text(markup), markup)
251 print(grid)