mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
markup parse and tests
This commit is contained in:
@@ -271,6 +271,7 @@ class Content(Visual):
|
||||
return new_content
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
"""Compares text only, so that markup doesn't effect sorting."""
|
||||
if isinstance(other, str):
|
||||
return self.plain == other
|
||||
elif isinstance(other, Content):
|
||||
@@ -284,6 +285,25 @@ class Content(Visual):
|
||||
return self.plain < other.plain
|
||||
return NotImplemented
|
||||
|
||||
def is_same(self, content: Content) -> bool:
|
||||
"""Compare to another Content object.
|
||||
|
||||
Two Content objects are the same if their text and spans match.
|
||||
Note that if you use the `==` operator to compare Content instances, it will only consider the plain text.
|
||||
|
||||
|
||||
Args:
|
||||
content: Content instance.
|
||||
|
||||
Returns:
|
||||
`True` if this is identical to `content`, otherwise False
|
||||
"""
|
||||
if self is content:
|
||||
return True
|
||||
if self.plain != content.plain:
|
||||
return False
|
||||
return self.spans == content.spans
|
||||
|
||||
def get_optimal_width(
|
||||
self,
|
||||
rules: RulesMap,
|
||||
|
||||
@@ -283,7 +283,7 @@ class Tokenizer:
|
||||
match = expect.match(line, col_no)
|
||||
|
||||
if match is None:
|
||||
error_line = line[col_no:].rstrip()
|
||||
error_line = line[col_no:]
|
||||
error_message = (
|
||||
f"{expect.description} (found {error_line.split(';')[0]!r})."
|
||||
)
|
||||
|
||||
@@ -5,19 +5,7 @@ from textual.css.parse import substitute_references
|
||||
__all__ = ["MarkupError", "escape", "to_content"]
|
||||
|
||||
import re
|
||||
from ast import literal_eval
|
||||
from operator import attrgetter
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Callable,
|
||||
Iterable,
|
||||
List,
|
||||
Match,
|
||||
NamedTuple,
|
||||
Optional,
|
||||
Tuple,
|
||||
Union,
|
||||
)
|
||||
from typing import TYPE_CHECKING, Callable, Match
|
||||
|
||||
from textual._context import active_app
|
||||
from textual.color import Color
|
||||
@@ -49,7 +37,7 @@ expect_markup_tag = Expect(
|
||||
token=TOKEN,
|
||||
variable_ref=VARIABLE_REF,
|
||||
whitespace=r"\s+",
|
||||
)
|
||||
).expect_eof()
|
||||
|
||||
expect_markup = Expect(
|
||||
"markup token",
|
||||
@@ -72,7 +60,7 @@ expect_markup_expression = Expect(
|
||||
whitespace=r"\s+",
|
||||
double_string=r"\".*?\"",
|
||||
single_string=r"'.*?'",
|
||||
)
|
||||
).expect_eof()
|
||||
|
||||
|
||||
class MarkupTokenizer(TokenizerState):
|
||||
@@ -115,7 +103,7 @@ class StyleTokenizer(TokenizerState):
|
||||
"""Tokenizes a style"""
|
||||
|
||||
EXPECT = expect_style.expect_eof()
|
||||
STATE_MAP = {"key": expect_markup_expression.expect_eof()}
|
||||
STATE_MAP = {"key": expect_markup_expression}
|
||||
STATE_PUSH = {
|
||||
"round_start": expect_markup_expression,
|
||||
"square_start": expect_markup_expression,
|
||||
@@ -133,6 +121,35 @@ STYLE_ABBREVIATIONS = {
|
||||
"s": "strike",
|
||||
}
|
||||
|
||||
_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)
|
||||
if markup.endswith("\\") and not markup.endswith("\\\\"):
|
||||
return markup + "\\"
|
||||
|
||||
return markup
|
||||
|
||||
|
||||
def parse_style(style: str, variables: dict[str, str] | None = None) -> Style:
|
||||
|
||||
@@ -171,9 +188,12 @@ def parse_style(style: str, variables: dict[str, str] | None = None) -> Style:
|
||||
parenthesis: list[str] = []
|
||||
value_text: list[str] = []
|
||||
first_token = next(iter_tokens)
|
||||
print("\t", repr(first_token))
|
||||
if first_token.name in {"double_string", "single_string"}:
|
||||
meta[key] = first_token.value[1:-1]
|
||||
break
|
||||
else:
|
||||
value_text.append(first_token.value)
|
||||
for token in iter_tokens:
|
||||
print("\t", repr(token))
|
||||
if token.name == "whitespace" and not parenthesis:
|
||||
@@ -228,232 +248,12 @@ def parse_style(style: str, variables: dict[str, str] | None = None) -> Style:
|
||||
color = color.multiply_alpha(percent)
|
||||
|
||||
parsed_style = Style(background, color, link=meta.get("link", None), **styles)
|
||||
|
||||
if meta:
|
||||
parsed_style += Style.from_meta(meta)
|
||||
return parsed_style
|
||||
|
||||
|
||||
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}"
|
||||
)
|
||||
|
||||
@property
|
||||
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)
|
||||
if markup.endswith("\\") and not markup.endswith("\\\\"):
|
||||
return 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 to_content(
|
||||
markup: str,
|
||||
style: Union[str, Style] = "",
|
||||
) -> Content:
|
||||
"""Render console markup in to a Text instance.
|
||||
|
||||
Args:
|
||||
markup (str): A string containing console markup.
|
||||
style: (Union[str, Style]): Base style for entire content, or empty string for no base style.
|
||||
|
||||
Raises:
|
||||
MarkupError: If there is a syntax error in the markup.
|
||||
|
||||
Returns:
|
||||
Text: A test instance.
|
||||
"""
|
||||
_rich_traceback_omit = True
|
||||
|
||||
from textual.content import Content, Span
|
||||
|
||||
if "[" not in markup:
|
||||
return Content(markup)
|
||||
|
||||
text: list[str] = []
|
||||
append = text.append
|
||||
text_length = 0
|
||||
|
||||
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(plain_text)
|
||||
text_length += len(plain_text)
|
||||
elif tag is not None:
|
||||
if tag.name.startswith("/"): # Closing tag
|
||||
style_name = tag.name[1:].strip()
|
||||
|
||||
if style_name: # explicit close
|
||||
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,
|
||||
text_length,
|
||||
Style.from_meta({open_tag.name: meta_params}),
|
||||
)
|
||||
)
|
||||
else:
|
||||
append_span(_Span(start, text_length, str(open_tag)))
|
||||
|
||||
else: # Opening tag
|
||||
normalized_tag = _Tag(tag.name, tag.parameters)
|
||||
style_stack.append((text_length, normalized_tag))
|
||||
|
||||
while style_stack:
|
||||
start, tag = style_stack.pop()
|
||||
style = str(tag)
|
||||
if style:
|
||||
append_span(_Span(start, text_length, style))
|
||||
|
||||
content = Content("".join(text), sorted(spans[::-1], key=attrgetter("start")))
|
||||
return content
|
||||
|
||||
|
||||
def to_content(markup: str, style: str | Style = "") -> Content:
|
||||
|
||||
from textual.content import Content, Span
|
||||
@@ -474,28 +274,27 @@ def to_content(markup: str, style: str | Style = "") -> Content:
|
||||
if token_name == "text":
|
||||
text.append(token.value)
|
||||
position += len(token.value)
|
||||
|
||||
elif token_name == "open_tag":
|
||||
tag_text = []
|
||||
|
||||
for token in iter_tokens:
|
||||
|
||||
if token.name == "end_tag":
|
||||
break
|
||||
tag_text.append(token.value)
|
||||
opening_tag = "".join(tag_text)
|
||||
opening_tag = "".join(tag_text).strip()
|
||||
style_stack.append((position, opening_tag))
|
||||
|
||||
elif token_name == "open_closing_tag":
|
||||
tag_text = []
|
||||
|
||||
for token in iter_tokens:
|
||||
|
||||
if token.name == "end_tag":
|
||||
break
|
||||
tag_text.append(token.value)
|
||||
closing_tag = "".join(tag_text)
|
||||
closing_tag = "".join(tag_text).strip()
|
||||
if closing_tag:
|
||||
for index, (tag_position, tag_body) in enumerate(reversed(style_stack)):
|
||||
for index, (tag_position, tag_body) in enumerate(
|
||||
reversed(style_stack), 1
|
||||
):
|
||||
if tag_body == closing_tag:
|
||||
style_stack.pop(-index)
|
||||
spans.append(Span(tag_position, position, tag_body))
|
||||
@@ -503,15 +302,18 @@ def to_content(markup: str, style: str | Style = "") -> Content:
|
||||
|
||||
else:
|
||||
open_position, tag = style_stack.pop()
|
||||
spans.append(Span(open_position, position, Style.parse(tag)))
|
||||
spans.append(Span(open_position, position, tag))
|
||||
|
||||
content_text = "".join(text)
|
||||
text_length = len(content_text)
|
||||
while style_stack:
|
||||
position, tag = style_stack.pop()
|
||||
spans.append(Span(position, text_length, Style.parse(tag)))
|
||||
spans.append(Span(position, text_length, tag))
|
||||
|
||||
content = Content(content_text, spans)
|
||||
if style:
|
||||
content = Content(content_text, [Span(0, len(content_text), style), *spans])
|
||||
else:
|
||||
content = Content(content_text, spans)
|
||||
|
||||
return content
|
||||
|
||||
@@ -553,7 +355,7 @@ if __name__ == "__main__": # pragma: no cover
|
||||
yield (text_area := TextArea(id="editor"))
|
||||
text_area.border_title = "Markup"
|
||||
|
||||
with (container := containers.VerticalScroll(id="results-container")):
|
||||
with containers.VerticalScroll(id="results-container") as container:
|
||||
yield Static(id="results")
|
||||
container.border_title = "Output"
|
||||
|
||||
|
||||
@@ -61,6 +61,31 @@ class Style:
|
||||
and self._meta is None
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def hash(self) -> int:
|
||||
return hash(
|
||||
(
|
||||
self.background,
|
||||
self.foreground,
|
||||
self.bold,
|
||||
self.italic,
|
||||
self.underline,
|
||||
self.reverse,
|
||||
self.strike,
|
||||
self.link,
|
||||
self.auto_color,
|
||||
self._meta,
|
||||
)
|
||||
)
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return self.hash
|
||||
|
||||
def __eq__(self, other: Any) -> bool:
|
||||
if not isinstance(other, Style):
|
||||
return NotImplemented
|
||||
return self.hash == other.hash
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return not self._is_null
|
||||
|
||||
@@ -101,7 +126,11 @@ class Style:
|
||||
self.reverse if other.reverse is None else other.reverse,
|
||||
self.strike if other.strike is None else other.strike,
|
||||
self.link if other.link is None else other.link,
|
||||
self._meta if other._meta is None else other._meta,
|
||||
(
|
||||
dumps({**self.meta, **other.meta})
|
||||
if self._meta is not None and other._meta is not None
|
||||
else (self._meta if other._meta is None else other._meta)
|
||||
),
|
||||
)
|
||||
return new_style
|
||||
elif other is None:
|
||||
@@ -118,9 +147,9 @@ class Style:
|
||||
|
||||
@classmethod
|
||||
def parse(cls, text_style: str, variables: dict[str, str] | None = None) -> Style:
|
||||
from textual._style_parse import style_parse
|
||||
from textual.markup import parse_style
|
||||
|
||||
return style_parse(text_style, variables)
|
||||
return parse_style(text_style, variables)
|
||||
|
||||
@classmethod
|
||||
def from_rich_style(
|
||||
@@ -183,7 +212,7 @@ class Style:
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_meta(cls, meta: dict[str, object]) -> Style:
|
||||
def from_meta(cls, meta: dict[str, str]) -> Style:
|
||||
"""Create a Visual Style containing meta information.
|
||||
|
||||
Args:
|
||||
@@ -192,7 +221,7 @@ class Style:
|
||||
Returns:
|
||||
A new Style.
|
||||
"""
|
||||
return Style(_meta=dumps(meta))
|
||||
return Style(_meta=dumps({**meta}))
|
||||
|
||||
@cached_property
|
||||
def rich_style(self) -> RichStyle:
|
||||
|
||||
@@ -213,18 +213,25 @@ def test_render_border_label():
|
||||
border_style = Style.parse("green on blue")
|
||||
|
||||
# Implicit test on the number of segments returned:
|
||||
blank1, what, is_up, with_you, blank2 = render_border_label(
|
||||
(Content.from_markup(label), Style()),
|
||||
True,
|
||||
"round",
|
||||
9999,
|
||||
Style(),
|
||||
Style(),
|
||||
border_style,
|
||||
True,
|
||||
True,
|
||||
segments = list(
|
||||
render_border_label(
|
||||
(Content.from_markup(label), Style.null()),
|
||||
True,
|
||||
"round",
|
||||
9999,
|
||||
Style(),
|
||||
Style(),
|
||||
border_style,
|
||||
True,
|
||||
True,
|
||||
)
|
||||
)
|
||||
|
||||
for segment in segments:
|
||||
print("!!", segment)
|
||||
|
||||
blank1, what, is_up, with_you, blank2 = segments
|
||||
|
||||
expected_blank = Segment(" ", border_style.rich_style)
|
||||
assert blank1 == expected_blank
|
||||
assert blank2 == expected_blank
|
||||
|
||||
45
tests/test_markup.py
Normal file
45
tests/test_markup.py
Normal file
@@ -0,0 +1,45 @@
|
||||
import pytest
|
||||
|
||||
from textual.content import Content, Span
|
||||
from textual.markup import to_content
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
["markup", "content"],
|
||||
[
|
||||
("", Content("")),
|
||||
("foo", Content("foo")),
|
||||
("foo\n", Content("foo\n")),
|
||||
("foo\nbar", Content("foo\nbar")),
|
||||
("[bold]Hello", Content("Hello", [Span(0, 5, "bold")])),
|
||||
(
|
||||
"[bold rgb(10, 20, 30)]Hello",
|
||||
Content("Hello", [Span(0, 5, "bold rgb(10, 20, 30)")]),
|
||||
),
|
||||
(
|
||||
"[bold red]Hello[/] World",
|
||||
Content("Hello World", [Span(0, 5, "bold red")]),
|
||||
),
|
||||
(
|
||||
"[bold red]Hello",
|
||||
Content("Hello", [Span(0, 5, "bold red")]),
|
||||
),
|
||||
(
|
||||
"[bold red]Hello[/]\nWorld",
|
||||
Content("Hello\nWorld", [Span(0, 5, "bold red")]),
|
||||
),
|
||||
(
|
||||
"[b][on red]What [i]is up[/on red] with you?[/]",
|
||||
Content("What is up with you?"),
|
||||
),
|
||||
(
|
||||
"[b]Welcome to Textual[/b]\n\nI must not fear",
|
||||
Content("Welcome to Textual\n\nI must not fear"),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_to_content(markup: str, content: Content):
|
||||
markup_content = to_content(markup)
|
||||
print(repr(markup_content))
|
||||
print(repr(content))
|
||||
assert markup_content.is_same(content)
|
||||
56
tests/test_style_parse.py
Normal file
56
tests/test_style_parse.py
Normal file
@@ -0,0 +1,56 @@
|
||||
import pytest
|
||||
|
||||
from textual.color import Color
|
||||
from textual.style import Style
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
["markup", "style"],
|
||||
[
|
||||
("", Style()),
|
||||
(
|
||||
"b",
|
||||
Style(bold=True),
|
||||
),
|
||||
("i", Style(italic=True)),
|
||||
("u", Style(underline=True)),
|
||||
("r", Style(reverse=True)),
|
||||
("bold", Style(bold=True)),
|
||||
("italic", Style(italic=True)),
|
||||
("underline", Style(underline=True)),
|
||||
("reverse", Style(reverse=True)),
|
||||
("bold italic", Style(bold=True, italic=True)),
|
||||
("not bold italic", Style(bold=False, italic=True)),
|
||||
("bold not italic", Style(bold=True, italic=False)),
|
||||
("rgb(10, 20, 30)", Style(foreground=Color(10, 20, 30))),
|
||||
("rgba(10, 20, 30, 0.5)", Style(foreground=Color(10, 20, 30, 0.5))),
|
||||
("rgb(10, 20, 30) 50%", Style(foreground=Color(10, 20, 30, 0.5))),
|
||||
("on rgb(10, 20, 30)", Style(background=Color(10, 20, 30))),
|
||||
("on rgb(10, 20, 30) 50%", Style(background=Color(10, 20, 30, 0.5))),
|
||||
("@click=app.bell", Style.from_meta({"@click": "app.bell"})),
|
||||
("@click='app.bell'", Style.from_meta({"@click": "app.bell"})),
|
||||
('''@click="app.bell"''', Style.from_meta({"@click": "app.bell"})),
|
||||
("@click=app.bell()", Style.from_meta({"@click": "app.bell()"})),
|
||||
(
|
||||
"@click=app.notify('hello')",
|
||||
Style.from_meta({"@click": "app.notify('hello')"}),
|
||||
),
|
||||
(
|
||||
"@click=app.notify('hello [World]!')",
|
||||
Style.from_meta({"@click": "app.notify('hello [World]!')"}),
|
||||
),
|
||||
(
|
||||
"@click=app.notify('hello') bold",
|
||||
Style(bold=True) + Style.from_meta({"@click": "app.notify('hello')"}),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_parse_style(markup: str, style: Style) -> None:
|
||||
"""Check parsing of valid styles."""
|
||||
parsed_style = Style.parse(markup)
|
||||
print("parsed\t\t", repr(parsed_style))
|
||||
print("expected\t", repr(style))
|
||||
print(parsed_style.meta, style.meta)
|
||||
print(parsed_style._meta)
|
||||
print(style._meta)
|
||||
assert parsed_style == style
|
||||
Reference in New Issue
Block a user