markup parse and tests

This commit is contained in:
Will McGugan
2025-01-31 19:12:04 +00:00
parent 381a1bc5a3
commit 1eed935a41
7 changed files with 223 additions and 264 deletions

View File

@@ -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,

View File

@@ -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})."
)

View File

@@ -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"

View File

@@ -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:

View File

@@ -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
View 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
View 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