2 from ast
import literal_eval
3 from operator
import attrgetter
4 from typing
import Callable
, Iterable
, List
, Match
, NamedTuple
, Optional
, Tuple
, Union
6 from ._emoji
_replace
import _emoji_replace
7 from .emoji
import EmojiVariant
8 from .errors
import MarkupError
9 from .style
import Style
10 from .text
import Span
, Text
13 r
"""((\\*)\[([a-z#/@][^[]*?)])""",
17 RE_HANDLER
= re
.compile(r
"^([\w.]*?)(\(.*?\))?$")
20 class Tag(NamedTuple
):
21 """A tag in console markup."""
24 """The tag name. e.g. 'bold'."""
25 parameters
: Optional
[str]
26 """Any additional parameters after the name."""
28 def __str__(self
) -> str:
30 self
.name
if self
.parameters
is None else f
"{self.name} {self.parameters}"
34 def markup(self
) -> str:
35 """Get the string representation of this tag."""
38 if self
.parameters
is None
39 else f
"[{self.name}={self.parameters}]"
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
50 _escape
: _EscapeSubMethod
= re
.compile(r
"(\\*)(\[[a-z#/@][^[]*?])").sub
,
52 """Escapes text so that it won't be interpreted as markup.
55 markup (str): Content to be inserted in to markup.
58 str: Markup with square brackets escaped.
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
)
70 def _parse(markup
: str) -> Iterable
[Tuple
[int, Optional
[str], Optional
[Tag
]]]:
71 """Parse markup in to an iterable of tuples of (position, text, tag).
74 markup (str): A string containing console markup
80 for match
in RE_TAGS
.finditer(markup
):
81 full_text
, escapes
, tag_text
= match
.groups()
82 start
, end
= match
.span()
84 yield start
, markup
[position
:start
], None
86 backslashes
, escaped
= _divmod(len(escapes
), 2)
89 yield start
, "\\" * backslashes
, None
90 start
+= backslashes
* 2
93 yield start
, full_text
[len(escapes
) :], None
96 text
, equals
, parameters
= tag_text
.partition("=")
97 yield start
, None, _Tag(text
, parameters
if equals
else None)
99 if position
< len(markup
):
100 yield position
, markup
[position
:], None
105 style
: Union
[str, Style
] = "",
107 emoji_variant
: Optional
[EmojiVariant
] = None,
109 """Render console markup in to a Text instance.
112 markup (str): A string containing console markup.
113 emoji (bool, optional): Also render emoji code. Defaults to True.
116 MarkupError: If there is a syntax error in the markup.
119 Text: A test instance.
121 emoji_replace
= _emoji_replace
122 if "[" not in markup
:
124 emoji_replace(markup
, default_variant
=emoji_variant
) if emoji
else markup
,
127 text
= Text(style
=style
)
129 normalize
= Style
.normalize
131 style_stack
: List
[Tuple
[int, Tag
]] = []
132 pop
= style_stack
.pop
134 spans
: List
[Span
] = []
135 append_span
= spans
.append
140 def pop_style(style_name
: str) -> Tuple
[int, Tag
]:
141 """Pop tag matching given style name."""
142 for index
, (_
, tag
) in enumerate(reversed(style_stack
), 1):
143 if tag
.name
== style_name
:
145 raise KeyError(style_name
)
147 for position
, plain_text
, tag
in _parse(markup
):
148 if plain_text
is not None:
149 # Handle open brace escapes, where the brace is not part of a tag.
150 plain_text
= plain_text
.replace("\\[", "[")
151 append(emoji_replace(plain_text
) if emoji
else plain_text
)
152 elif tag
is not None:
153 if tag
.name
.startswith("/"): # Closing tag
154 style_name
= tag
.name
[1:].strip()
156 if style_name
: # explicit close
157 style_name
= normalize(style_name
)
159 start
, open_tag
= pop_style(style_name
)
162 f
"closing tag '{tag.markup}' at position {position} doesn't match any open tag"
164 else: # implicit close
166 start
, open_tag
= pop()
169 f
"closing tag '[/]' at position {position} has nothing to close"
172 if open_tag
.name
.startswith("@"):
173 if open_tag
.parameters
:
175 parameters
= open_tag
.parameters
.strip()
176 handler_match
= RE_HANDLER
.match(parameters
)
177 if handler_match
is not None:
178 handler_name
, match_parameters
= handler_match
.groups()
180 "()" if match_parameters
is None else match_parameters
184 meta_params
= literal_eval(parameters
)
185 except SyntaxError as error
:
187 f
"error parsing {parameters!r} in {open_tag.parameters!r}; {error.msg}"
189 except Exception as error
:
191 f
"error parsing {open_tag.parameters!r}; {error}"
198 if isinstance(meta_params
, tuple)
207 start
, len(text
), Style(meta
={open_tag.name: meta_params}
)
211 append_span(_Span(start
, len(text
), str(open_tag
)))
214 normalized_tag
= _Tag(normalize(tag
.name
), tag
.parameters
)
215 style_stack
.append((len(text
), normalized_tag
))
217 text_length
= len(text
)
219 start
, tag
= style_stack
.pop()
222 append_span(_Span(start
, text_length
, style
))
224 text
.spans
= sorted(spans
[::-1], key
=attrgetter("start"))
228 if __name__
== "__main__": # pragma: no cover
231 "[red]Hello World[/red]",
232 "[magenta]Hello [b]World[/b]",
233 "[bold]Bold[italic] bold and italic [/bold]italic[/italic]",
234 "Click [link=https://www.willmcgugan.com]here[/link] to visit my Blog",
235 ":warning-emoji: [bold red blink] DANGER![/]",
238 from pip
._vendor
.rich
import print
239 from pip
._vendor
.rich
.table
import Table
241 grid
= Table("Markup", "Result", padding
=(0, 1))
243 for markup
in MARKUP
:
244 grid
.add_row(Text(markup
), markup
)