mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge pull request #445 from Textualize/did-you-mean-feature-for-css-properties
[css] Add a "did you mean" suggestion when an unknown CSS property is spotted
This commit is contained in:
@@ -128,7 +128,32 @@ def _spacing_examples(property_name: str) -> ContextSpecificBullets:
|
||||
)
|
||||
|
||||
|
||||
def spacing_wrong_number_of_values(
|
||||
def property_invalid_value_help_text(
|
||||
property_name: str, context: StylingContext, *, suggested_property_name: str = None
|
||||
) -> HelpText:
|
||||
"""Help text to show when the user supplies an invalid value for CSS property
|
||||
property.
|
||||
|
||||
Args:
|
||||
property_name (str): The name of the property
|
||||
context (StylingContext | None): The context the spacing property is being used in.
|
||||
Keyword Args:
|
||||
suggested_property_name (str | None): A suggested name for the property (e.g. "width" for "wdth"). Defaults to None.
|
||||
|
||||
Returns:
|
||||
HelpText: Renderable for displaying the help text for this property
|
||||
"""
|
||||
property_name = _contextualize_property_name(property_name, context)
|
||||
bullets = []
|
||||
if suggested_property_name:
|
||||
suggested_property_name = _contextualize_property_name(
|
||||
suggested_property_name, context
|
||||
)
|
||||
bullets.append(Bullet(f'Did you mean "{suggested_property_name}"?'))
|
||||
return HelpText(f"Invalid CSS property [i]{property_name}[/]", bullets=bullets)
|
||||
|
||||
|
||||
def spacing_wrong_number_of_values_help_text(
|
||||
property_name: str,
|
||||
num_values_supplied: int,
|
||||
context: StylingContext,
|
||||
@@ -159,7 +184,7 @@ def spacing_wrong_number_of_values(
|
||||
)
|
||||
|
||||
|
||||
def spacing_invalid_value(
|
||||
def spacing_invalid_value_help_text(
|
||||
property_name: str,
|
||||
context: StylingContext,
|
||||
) -> HelpText:
|
||||
|
||||
@@ -22,7 +22,7 @@ from ._help_text import (
|
||||
style_flags_property_help_text,
|
||||
)
|
||||
from ._help_text import (
|
||||
spacing_wrong_number_of_values,
|
||||
spacing_wrong_number_of_values_help_text,
|
||||
scalar_help_text,
|
||||
string_enum_help_text,
|
||||
color_property_help_text,
|
||||
@@ -415,7 +415,7 @@ class SpacingProperty:
|
||||
except ValueError as error:
|
||||
raise StyleValueError(
|
||||
str(error),
|
||||
help_text=spacing_wrong_number_of_values(
|
||||
help_text=spacing_wrong_number_of_values_help_text(
|
||||
property_name=self.name,
|
||||
num_values_supplied=len(spacing),
|
||||
context="inline",
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import cast, Iterable, NoReturn
|
||||
from functools import lru_cache
|
||||
from typing import cast, Iterable, NoReturn, Sequence
|
||||
|
||||
import rich.repr
|
||||
|
||||
from ._error_tools import friendly_list
|
||||
from ._help_renderables import HelpText
|
||||
from ._help_text import (
|
||||
spacing_invalid_value,
|
||||
spacing_wrong_number_of_values,
|
||||
spacing_invalid_value_help_text,
|
||||
spacing_wrong_number_of_values_help_text,
|
||||
scalar_help_text,
|
||||
color_property_help_text,
|
||||
string_enum_help_text,
|
||||
@@ -21,6 +22,7 @@ from ._help_text import (
|
||||
offset_property_help_text,
|
||||
offset_single_axis_help_text,
|
||||
style_flags_property_help_text,
|
||||
property_invalid_value_help_text,
|
||||
)
|
||||
from .constants import (
|
||||
VALID_ALIGN_HORIZONTAL,
|
||||
@@ -44,6 +46,7 @@ from ..color import Color, ColorParseError
|
||||
from .._duration import _duration_as_seconds
|
||||
from .._easing import EASING
|
||||
from ..geometry import Spacing, SpacingDimensions, clamp
|
||||
from ..suggestions import get_suggestion
|
||||
|
||||
|
||||
def _join_tokens(tokens: Iterable[Token], joiner: str = "") -> str:
|
||||
@@ -84,26 +87,47 @@ class StylesBuilder:
|
||||
process_method = getattr(self, f"process_{rule_name}", None)
|
||||
|
||||
if process_method is None:
|
||||
suggested_property_name = self._get_suggested_property_name_for_rule(
|
||||
declaration.name
|
||||
)
|
||||
self.error(
|
||||
declaration.name,
|
||||
declaration.token,
|
||||
f"unknown declaration {declaration.name!r}",
|
||||
property_invalid_value_help_text(
|
||||
declaration.name,
|
||||
"css",
|
||||
suggested_property_name=suggested_property_name,
|
||||
),
|
||||
)
|
||||
else:
|
||||
tokens = declaration.tokens
|
||||
return
|
||||
|
||||
important = tokens[-1].name == "important"
|
||||
if important:
|
||||
tokens = tokens[:-1]
|
||||
self.styles.important.add(rule_name)
|
||||
try:
|
||||
process_method(declaration.name, tokens)
|
||||
except DeclarationError:
|
||||
raise
|
||||
except Exception as error:
|
||||
self.error(declaration.name, declaration.token, str(error))
|
||||
tokens = declaration.tokens
|
||||
|
||||
def _process_enum_multiple(
|
||||
important = tokens[-1].name == "important"
|
||||
if important:
|
||||
tokens = tokens[:-1]
|
||||
self.styles.important.add(rule_name)
|
||||
try:
|
||||
process_method(declaration.name, tokens)
|
||||
except DeclarationError:
|
||||
raise
|
||||
except Exception as error:
|
||||
self.error(declaration.name, declaration.token, str(error))
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def _get_processable_rule_names(self) -> Sequence[str]:
|
||||
"""
|
||||
Returns the list of CSS properties we can manage -
|
||||
i.e. the ones for which we have a `process_[property name]` method
|
||||
|
||||
Returns:
|
||||
Sequence[str]: All the "Python-ised" CSS property names this class can handle.
|
||||
|
||||
Example: ("width", "background", "offset_x", ...)
|
||||
"""
|
||||
return [attr[8:] for attr in dir(self) if attr.startswith("process_")]
|
||||
|
||||
def _get_process_enum_multiple(
|
||||
self, name: str, tokens: list[Token], valid_values: set[str], count: int
|
||||
) -> tuple[str, ...]:
|
||||
"""Generic code to process a declaration with two enumerations, like overflow: auto auto"""
|
||||
@@ -332,14 +356,20 @@ class StylesBuilder:
|
||||
try:
|
||||
append(int(value))
|
||||
except ValueError:
|
||||
self.error(name, token, spacing_invalid_value(name, context="css"))
|
||||
self.error(
|
||||
name,
|
||||
token,
|
||||
spacing_invalid_value_help_text(name, context="css"),
|
||||
)
|
||||
else:
|
||||
self.error(name, token, spacing_invalid_value(name, context="css"))
|
||||
self.error(
|
||||
name, token, spacing_invalid_value_help_text(name, context="css")
|
||||
)
|
||||
if len(space) not in (1, 2, 4):
|
||||
self.error(
|
||||
name,
|
||||
tokens[0],
|
||||
spacing_wrong_number_of_values(
|
||||
spacing_wrong_number_of_values_help_text(
|
||||
name, num_values_supplied=len(space), context="css"
|
||||
),
|
||||
)
|
||||
@@ -348,7 +378,9 @@ class StylesBuilder:
|
||||
def _process_space_partial(self, name: str, tokens: list[Token]) -> None:
|
||||
"""Process granular margin / padding declarations."""
|
||||
if len(tokens) != 1:
|
||||
self.error(name, tokens[0], spacing_invalid_value(name, context="css"))
|
||||
self.error(
|
||||
name, tokens[0], spacing_invalid_value_help_text(name, context="css")
|
||||
)
|
||||
|
||||
_EDGE_SPACING_MAP = {"top": 0, "right": 1, "bottom": 2, "left": 3}
|
||||
token = tokens[0]
|
||||
@@ -356,7 +388,9 @@ class StylesBuilder:
|
||||
if token_name == "number":
|
||||
space = int(value)
|
||||
else:
|
||||
self.error(name, token, spacing_invalid_value(name, context="css"))
|
||||
self.error(
|
||||
name, token, spacing_invalid_value_help_text(name, context="css")
|
||||
)
|
||||
style_name, _, edge = name.replace("-", "_").partition("_")
|
||||
|
||||
current_spacing = cast(
|
||||
@@ -717,3 +751,18 @@ class StylesBuilder:
|
||||
process_content_align = process_align
|
||||
process_content_align_horizontal = process_align_horizontal
|
||||
process_content_align_vertical = process_align_vertical
|
||||
|
||||
def _get_suggested_property_name_for_rule(self, rule_name: str) -> str | None:
|
||||
"""
|
||||
Returns a valid CSS property "Python" name, or None if no close matches could be found.
|
||||
|
||||
Args:
|
||||
rule_name (str): An invalid "Python-ised" CSS property (i.e. "offst_x" rather than "offst-x")
|
||||
|
||||
Returns:
|
||||
str | None: The closest valid "Python-ised" CSS property.
|
||||
Returns `None` if no close matches could be found.
|
||||
|
||||
Example: returns "background" for rule_name "bkgrund", "offset_x" for "ofset_x"
|
||||
"""
|
||||
return get_suggestion(rule_name, self._get_processable_rule_names())
|
||||
|
||||
38
src/textual/suggestions.py
Normal file
38
src/textual/suggestions.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from difflib import get_close_matches
|
||||
from typing import Sequence
|
||||
|
||||
|
||||
def get_suggestion(word: str, possible_words: Sequence[str]) -> str | None:
|
||||
"""
|
||||
Returns a close match of `word` amongst `possible_words`.
|
||||
|
||||
Args:
|
||||
word (str): The word we want to find a close match for
|
||||
possible_words (Sequence[str]): The words amongst which we want to find a close match
|
||||
|
||||
Returns:
|
||||
str | None: The closest match amongst the `possible_words`. Returns `None` if no close matches could be found.
|
||||
|
||||
Example: returns "red" for word "redu" and possible words ("yellow", "red")
|
||||
"""
|
||||
possible_matches = get_close_matches(word, possible_words, n=1)
|
||||
return None if not possible_matches else possible_matches[0]
|
||||
|
||||
|
||||
def get_suggestions(word: str, possible_words: Sequence[str], count: int) -> list[str]:
|
||||
"""
|
||||
Returns a list of up to `count` matches of `word` amongst `possible_words`.
|
||||
|
||||
Args:
|
||||
word (str): The word we want to find a close match for
|
||||
possible_words (Sequence[str]): The words amongst which we want to find close matches
|
||||
|
||||
Returns:
|
||||
list[str]: The closest matches amongst the `possible_words`, from the closest to the least close.
|
||||
Returns an empty list if no close matches could be found.
|
||||
|
||||
Example: returns ["yellow", "ellow"] for word "yllow" and possible words ("yellow", "red", "ellow")
|
||||
"""
|
||||
return get_close_matches(word, possible_words, n=count)
|
||||
@@ -1,10 +1,22 @@
|
||||
import pytest
|
||||
|
||||
from tests.utilities.render import render
|
||||
from textual.css._help_text import spacing_wrong_number_of_values, spacing_invalid_value, scalar_help_text, \
|
||||
string_enum_help_text, color_property_help_text, border_property_help_text, layout_property_help_text, \
|
||||
docks_property_help_text, dock_property_help_text, fractional_property_help_text, offset_property_help_text, \
|
||||
align_help_text, offset_single_axis_help_text, style_flags_property_help_text
|
||||
from textual.css._help_text import (
|
||||
spacing_wrong_number_of_values_help_text,
|
||||
spacing_invalid_value_help_text,
|
||||
scalar_help_text,
|
||||
string_enum_help_text,
|
||||
color_property_help_text,
|
||||
border_property_help_text,
|
||||
layout_property_help_text,
|
||||
docks_property_help_text,
|
||||
dock_property_help_text,
|
||||
fractional_property_help_text,
|
||||
offset_property_help_text,
|
||||
align_help_text,
|
||||
offset_single_axis_help_text,
|
||||
style_flags_property_help_text,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(params=["css", "inline"])
|
||||
@@ -15,22 +27,24 @@ def styling_context(request):
|
||||
def test_help_text_examples_are_contextualized():
|
||||
"""Ensure that if the user is using CSS, they see CSS-specific examples
|
||||
and if they're using inline styles they see inline-specific examples."""
|
||||
rendered_inline = render(spacing_invalid_value("padding", "inline"))
|
||||
rendered_inline = render(spacing_invalid_value_help_text("padding", "inline"))
|
||||
assert "widget.styles.padding" in rendered_inline
|
||||
|
||||
rendered_css = render(spacing_invalid_value("padding", "css"))
|
||||
rendered_css = render(spacing_invalid_value_help_text("padding", "css"))
|
||||
assert "padding:" in rendered_css
|
||||
|
||||
|
||||
def test_spacing_wrong_number_of_values(styling_context):
|
||||
rendered = render(spacing_wrong_number_of_values("margin", 3, styling_context))
|
||||
rendered = render(
|
||||
spacing_wrong_number_of_values_help_text("margin", 3, styling_context)
|
||||
)
|
||||
assert "Invalid number of values" in rendered
|
||||
assert "margin" in rendered
|
||||
assert "3" in rendered
|
||||
|
||||
|
||||
def test_spacing_invalid_value(styling_context):
|
||||
rendered = render(spacing_invalid_value("padding", styling_context))
|
||||
rendered = render(spacing_invalid_value_help_text("padding", styling_context))
|
||||
assert "Invalid value for" in rendered
|
||||
assert "padding" in rendered
|
||||
|
||||
@@ -47,7 +61,9 @@ def test_scalar_help_text(styling_context):
|
||||
|
||||
|
||||
def test_string_enum_help_text(styling_context):
|
||||
rendered = render(string_enum_help_text("display", ["none", "hidden"], styling_context))
|
||||
rendered = render(
|
||||
string_enum_help_text("display", ["none", "hidden"], styling_context)
|
||||
)
|
||||
assert "Invalid value for" in rendered
|
||||
|
||||
# Ensure property name is mentioned
|
||||
@@ -113,7 +129,9 @@ def test_offset_single_axis_help_text():
|
||||
|
||||
|
||||
def test_style_flags_property_help_text(styling_context):
|
||||
rendered = render(style_flags_property_help_text("text-style", "notavalue b", styling_context))
|
||||
rendered = render(
|
||||
style_flags_property_help_text("text-style", "notavalue b", styling_context)
|
||||
)
|
||||
assert "Invalid value" in rendered
|
||||
assert "notavalue" in rendered
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
from contextlib import nullcontext as does_not_raise
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from textual.color import Color
|
||||
from textual.css._help_renderables import HelpText
|
||||
from textual.css.stylesheet import Stylesheet, StylesheetParseError
|
||||
from textual.css.tokenizer import TokenizeError
|
||||
|
||||
@@ -53,3 +56,50 @@ def test_color_property_parsing(css_value, expectation, expected_color):
|
||||
if expected_color:
|
||||
css_rule = stylesheet.rules[0]
|
||||
assert css_rule.styles.background == expected_color
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"css_property_name,expected_property_name_suggestion",
|
||||
[
|
||||
["backgroundu", "background"],
|
||||
["bckgroundu", "background"],
|
||||
["ofset-x", "offset-x"],
|
||||
["ofst_y", "offset-y"],
|
||||
["colr", "color"],
|
||||
["colour", "color"],
|
||||
["wdth", "width"],
|
||||
["wth", "width"],
|
||||
["wh", None],
|
||||
["xkcd", None],
|
||||
],
|
||||
)
|
||||
def test_did_you_mean_for_css_property_names(
|
||||
css_property_name: str, expected_property_name_suggestion
|
||||
):
|
||||
stylesheet = Stylesheet()
|
||||
css = """
|
||||
* {
|
||||
border: blue;
|
||||
${PROPERTY}: red;
|
||||
}
|
||||
""".replace(
|
||||
"${PROPERTY}", css_property_name
|
||||
)
|
||||
|
||||
stylesheet.add_source(css)
|
||||
with pytest.raises(StylesheetParseError) as err:
|
||||
stylesheet.parse()
|
||||
|
||||
_, help_text = err.value.errors.rules[0].errors[0] # type: Any, HelpText
|
||||
displayed_css_property_name = css_property_name.replace("_", "-")
|
||||
assert (
|
||||
help_text.summary == f"Invalid CSS property [i]{displayed_css_property_name}[/]"
|
||||
)
|
||||
|
||||
expected_bullets_length = 1 if expected_property_name_suggestion else 0
|
||||
assert len(help_text.bullets) == expected_bullets_length
|
||||
if expected_property_name_suggestion is not None:
|
||||
expected_suggestion_message = (
|
||||
f'Did you mean "{expected_property_name_suggestion}"?'
|
||||
)
|
||||
assert help_text.bullets[0].markup == expected_suggestion_message
|
||||
|
||||
35
tests/test_suggestions.py
Normal file
35
tests/test_suggestions.py
Normal file
@@ -0,0 +1,35 @@
|
||||
import pytest
|
||||
|
||||
from textual.suggestions import get_suggestion, get_suggestions
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"word, possible_words, expected_result",
|
||||
(
|
||||
["background", ("background",), "background"],
|
||||
["backgroundu", ("background",), "background"],
|
||||
["bkgrund", ("background",), "background"],
|
||||
["llow", ("background",), None],
|
||||
["llow", ("background", "yellow"), "yellow"],
|
||||
["yllow", ("background", "yellow", "ellow"), "yellow"],
|
||||
),
|
||||
)
|
||||
def test_get_suggestion(word, possible_words, expected_result):
|
||||
assert get_suggestion(word, possible_words) == expected_result
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"word, possible_words, count, expected_result",
|
||||
(
|
||||
["background", ("background",), 1, ["background"]],
|
||||
["backgroundu", ("background",), 1, ["background"]],
|
||||
["bkgrund", ("background",), 1, ["background"]],
|
||||
["llow", ("background",), 1, []],
|
||||
["llow", ("background", "yellow"), 1, ["yellow"]],
|
||||
["yllow", ("background", "yellow", "ellow"), 1, ["yellow"]],
|
||||
["yllow", ("background", "yellow", "ellow"), 2, ["yellow", "ellow"]],
|
||||
["yllow", ("background", "yellow", "red"), 2, ["yellow"]],
|
||||
),
|
||||
)
|
||||
def test_get_suggestions(word, possible_words, count, expected_result):
|
||||
assert get_suggestions(word, possible_words, count) == expected_result
|
||||
Reference in New Issue
Block a user