diff --git a/sandbox/will/basic.css b/sandbox/will/basic.css index b4a150adf..d0406b4e9 100644 --- a/sandbox/will/basic.css +++ b/sandbox/will/basic.css @@ -2,9 +2,9 @@ -* { + * { transition: color 300ms linear, background 300ms linear; -} +} *:hover { @@ -29,8 +29,8 @@ DataTable { } #sidebar { - color: $text-primary; - background: $primary-background; + color: $text-panel; + background: $panel; dock: side; width: 30; offset-x: -100%; @@ -43,33 +43,35 @@ DataTable { } #sidebar .title { - height: 3; - background: $primary-background-darken-2; - color: $text-primary-darken-2 ; - border-right: outer $primary-darken-3; + height: 1; + background: $primary-background-darken-1; + color: $text-primary-background-darken-1; + border-right: wide $background; content-align: center middle; } #sidebar .user { height: 8; - background: $primary-background-darken-1; - color: $text-primary-darken-1; - border-right: outer $primary-background-darken-3; + background: $panel-darken-1; + color: $text-panel-darken-1; + border-right: wide $background; content-align: center middle; } #sidebar .content { - background: $primary-background; - color: $text-primary-background; - border-right: outer $primary-background-darken-3; + background: $surface; + color: $text-surface; + border-right: wide $background; content-align: center middle; } #header { - color: $text-secondary-background-darken-1; - background: $secondary-background-darken-1; - height: 3; + color: $text-secondary-background; + background: $secondary-background; + height: 1; content-align: center middle; + + } #content { @@ -90,7 +92,7 @@ Tweet { layout: vertical; /* border: outer $primary; */ padding: 1; - border: wide $panel-darken-2; + border: wide $panel; overflow: auto; /* scrollbar-gutter: stable; */ align-horizontal: center; @@ -175,16 +177,16 @@ Tweet.scroll-horizontal TweetBody { OptionItem { height: 3; - background: $primary-background; - border-right: outer $primary-background-darken-2; + background: $panel; + border-right: wide $background; border-left: blank; content-align: center middle; } OptionItem:hover { height: 3; - color: $secondary; - background: $primary-background-darken-1; + color: $text-primary; + background: $primary-darken-1; /* border-top: hkey $accent2-darken-3; border-bottom: hkey $accent2-darken-3; */ text-style: bold; @@ -196,8 +198,8 @@ Error { height:3; background: $error; color: $text-error; - border-top: wide $error-darken-1; - border-bottom: wide $error-darken-1; + border-top: tall $error-darken-1; + border-bottom: tall $error-darken-1; padding: 0; text-style: bold; @@ -209,8 +211,8 @@ Warning { height:3; background: $warning; color: $text-warning-fade-1; - border-top: wide $warning-darken-1; - border-bottom: wide $warning-darken-1; + border-top: tall $warning-darken-1; + border-bottom: tall $warning-darken-1; text-style: bold; align-horizontal: center; @@ -218,7 +220,7 @@ Warning { Success { width: 100%; - width:90%; + height:auto; box-sizing: border-box; background: $success; @@ -227,7 +229,7 @@ Success { border-top: hkey $success-darken-1; border-bottom: hkey $success-darken-1; - text-style: bold underline; + text-style: bold ; align-horizontal: center; } diff --git a/sandbox/will/basic.py b/sandbox/will/basic.py index 12fcb8846..c376d5c12 100644 --- a/sandbox/will/basic.py +++ b/sandbox/will/basic.py @@ -179,21 +179,21 @@ app = BasicApp() if __name__ == "__main__": app.run() - from textual.geometry import Region - from textual.color import Color + # from textual.geometry import Region + # from textual.color import Color - print(Region.intersection.cache_info()) - print(Region.overlaps.cache_info()) - print(Region.union.cache_info()) - print(Region.split_vertical.cache_info()) - print(Region.__contains__.cache_info()) - from textual.css.scalar import Scalar + # print(Region.intersection.cache_info()) + # print(Region.overlaps.cache_info()) + # print(Region.union.cache_info()) + # print(Region.split_vertical.cache_info()) + # print(Region.__contains__.cache_info()) + # from textual.css.scalar import Scalar - print(Scalar.resolve_dimension.cache_info()) + # print(Scalar.resolve_dimension.cache_info()) - from rich.style import Style - from rich.cells import cached_cell_len + # from rich.style import Style + # from rich.cells import cached_cell_len - print(Style._add.cache_info()) + # print(Style._add.cache_info()) - print(cached_cell_len.cache_info()) + # print(cached_cell_len.cache_info()) diff --git a/src/textual/app.py b/src/textual/app.py index 3b01ca5eb..3f90f3c27 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -80,16 +80,26 @@ _ASYNCIO_GET_EVENT_LOOP_IS_DEPRECATED = sys.version_info >= (3, 10, 0) LayoutDefinition = "dict[str, Any]" -DEFAULT_COLORS = ColorSystem( - primary="#2A4E6E", - secondary="#ffa62b", - warning="#ffa62b", - error="#ba3c5b", - success="#4EBF71", - accent="#1A75B4", - system="#5a4599", - dark_surface="#292929", -) +DEFAULT_COLORS = { + "dark": ColorSystem( + primary="#004578", + secondary="#ffa62b", + warning="#ffa62b", + error="#ba3c5b", + success="#4EBF71", + accent="#0178D4", + dark=True, + ), + "light": ColorSystem( + primary="#004578", + secondary="#ffa62b", + warning="#ffa62b", + error="#ba3c5b", + success="#4EBF71", + accent="#0178D4", + dark=False, + ), +} ComposeResult = Iterable[Widget] @@ -338,7 +348,7 @@ class App(Generic[ReturnType], DOMNode): Returns: dict[str, str]: A mapping of variable name to value. """ - variables = self.design.generate(self.dark) + variables = self.design["dark" if self.dark else "light"].generate() return variables def watch_dark(self, dark: bool) -> None: diff --git a/src/textual/css/errors.py b/src/textual/css/errors.py index eed330dbf..5830a2077 100644 --- a/src/textual/css/errors.py +++ b/src/textual/css/errors.py @@ -1,6 +1,6 @@ from __future__ import annotations -from rich.console import ConsoleOptions, Console +from rich.console import ConsoleOptions, Console, RenderResult from rich.traceback import Traceback from ._help_renderables import HelpText @@ -15,10 +15,6 @@ class DeclarationError(Exception): super().__init__(message) -class UnresolvedVariableError(NameError): - pass - - class StyleTypeError(TypeError): pass @@ -35,7 +31,9 @@ class StyleValueError(ValueError): super().__init__(*args) self.help_text = help_text - def __rich_console__(self, console: Console, options: ConsoleOptions): + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: yield Traceback.from_exception(type(self), self, self.__traceback__) if self.help_text is not None: yield "" diff --git a/src/textual/css/parse.py b/src/textual/css/parse.py index 6e3ddb4cd..cadc83627 100644 --- a/src/textual/css/parse.py +++ b/src/textual/css/parse.py @@ -2,11 +2,11 @@ from __future__ import annotations from functools import lru_cache from pathlib import PurePath -from typing import Iterator, Iterable +from typing import Iterator, Iterable, NoReturn, Sequence from rich import print -from textual.css.errors import UnresolvedVariableError +from textual.css.tokenizer import TokenError from textual.css.types import Specificity3 from ._styles_builder import StylesBuilder, DeclarationError from .model import ( @@ -18,6 +18,7 @@ from .model import ( SelectorType, ) from .styles import Styles +from ..suggestions import get_suggestion from .tokenize import tokenize, tokenize_declarations, Token, tokenize_values from .tokenizer import EOFError, ReferencedBy @@ -209,12 +210,20 @@ def parse_declarations(css: str, path: str) -> Styles: return styles_builder.styles -def _unresolved( - variable_name: str, location: tuple[int, int] -) -> UnresolvedVariableError: - line_idx, col_idx = location - return UnresolvedVariableError( - f"reference to undefined variable '${variable_name}' at line {line_idx + 1}, column {col_idx + 1}." +def _unresolved(variable_name: str, variables: Sequence[str], token: Token) -> NoReturn: + + message = f"reference to undefined variable '${variable_name}'" + + suggested_variable = get_suggestion(variable_name, variables) + if suggested_variable: + message += f"; did you mean '{suggested_variable}'?" + + raise TokenError( + token.path, + token.code, + token.start, + message, + end=token.end, ) @@ -284,9 +293,7 @@ def substitute_references( ) ) else: - raise _unresolved( - variable_name=ref_name, location=token.location - ) + _unresolved(ref_name, variables.keys(), token) else: variables.setdefault(variable_name, []).append(token) yield token @@ -306,7 +313,7 @@ def substitute_references( ) ) else: - raise _unresolved(variable_name=variable_name, location=token.location) + _unresolved(variable_name, variables.keys(), token) else: yield token diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index 02a641168..c7bb8b543 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -22,7 +22,7 @@ from .model import RuleSet from .parse import parse from .styles import RulesMap, Styles from .tokenize import tokenize_values, Token -from .tokenizer import TokenizeError +from .tokenizer import TokenError from .types import Specificity3, Specificity4 from ..dom import DOMNode from .. import messages @@ -198,7 +198,7 @@ class Stylesheet: is_default_rules=is_default_rules, ) ) - except TokenizeError: + except TokenError: raise except Exception as error: raise StylesheetError(f"failed to parse css; {error}") diff --git a/src/textual/css/tokenizer.py b/src/textual/css/tokenizer.py index 0860e3d25..bc1687bac 100644 --- a/src/textual/css/tokenizer.py +++ b/src/textual/css/tokenizer.py @@ -15,28 +15,43 @@ from rich.text import Text from .._loop import loop_last -class TokenizeError(Exception): +class TokenError(Exception): """Error raised when the CSS cannot be tokenized (syntax error).""" def __init__( - self, path: str, code: str, line_no: int, col_no: int, message: str + self, + path: str, + code: str, + start: tuple[int, int], + message: str, + end: tuple[int, int] | None = None, ) -> None: """ Args: path (str): Path to source or "" if source is parsed from a literal. code (str): The code being parsed. - line_no (int): Line number of the error. - col_no (int): Column number of the error. + start (tuple[int, int]): Line number of the error. message (str): A message associated with the error. + end (tuple[int, int]): End location of . """ + """_summary_ + + Args: + path (str): Path to source or "" if source is parsed from a literal. + code (str): The code being parsed. + start (tuple[int, int]): Location of the error. + message (str): A message associated with the error. + end (tuple[int, int] | None, optional): End location of the error, or None for + the same as start. Defaults to None. + """ + self.path = path self.code = code - self.line_no = line_no - self.col_no = col_no + self.start = start + self.end = end or start super().__init__(message) - @classmethod - def _get_snippet(cls, code: str, line_no: int) -> Panel: + def _get_snippet(self) -> Panel: """Get a short snippet of code around a given line number. Args: @@ -46,9 +61,10 @@ class TokenizeError(Exception): Returns: Panel: A renderable. """ + line_no = self.start[0] # TODO: Highlight column number syntax = Syntax( - code, + self.code, lexer="scss", theme="ansi_light", line_numbers=True, @@ -56,6 +72,7 @@ class TokenizeError(Exception): line_range=(max(0, line_no - 2), line_no + 2), highlight_lines={line_no}, ) + syntax.stylize_range("reverse bold", self.start, self.end) return Panel(syntax, border_style="red") def __rich__(self) -> RenderableType: @@ -63,14 +80,12 @@ class TokenizeError(Exception): errors: list[RenderableType] = [] message = str(self) - errors.append(Text(" Tokenizer error in stylesheet:", style="bold red")) + errors.append(Text(" Error in stylesheet:", style="bold red")) - errors.append( - highlighter( - f" {self.path or ''}:{self.line_no + 1}:{self.col_no + 1}" - ) - ) - errors.append(self._get_snippet(self.code, self.line_no + 1)) + line_no, col_no = self.start + + errors.append(highlighter(f" {self.path or ''}:{line_no}:{col_no}")) + errors.append(self._get_snippet()) final_message = "" for is_last, message_part in loop_last(message.split(";")): end = "" if is_last else "\n" @@ -80,7 +95,7 @@ class TokenizeError(Exception): return Group(*errors) -class EOFError(TokenizeError): +class EOFError(TokenError): pass @@ -120,6 +135,18 @@ class Token(NamedTuple): location: tuple[int, int] referenced_by: ReferencedBy | None = None + @property + def start(self) -> tuple[int, int]: + """Start line and column (1 indexed).""" + line, offset = self.location + return (line + 1, offset) + + @property + def end(self) -> tuple[int, int]: + """End line and column (1 indexed).""" + line, offset = self.location + return (line + 1, offset + len(self.value)) + def with_reference(self, by: ReferencedBy | None) -> "Token": """Return a copy of the Token, with reference information attached. This is used for variable substitution, where a variable reference @@ -161,19 +188,28 @@ class Tokenizer: col_no = self.col_no if line_no >= len(self.lines): if expect._expect_eof: - return Token("eof", "", self.path, self.code, (line_no, col_no), None) + return Token( + "eof", + "", + self.path, + self.code, + (line_no + 1, col_no + 1), + None, + ) else: raise EOFError( - self.path, self.code, line_no, col_no, "Unexpected end of file" + self.path, + self.code, + (line_no + 1, col_no + 1), + "Unexpected end of file", ) line = self.lines[line_no] match = expect.match(line, col_no) if match is None: - raise TokenizeError( + raise TokenError( self.path, self.code, - line_no, - col_no, + (line_no, col_no), "expected " + ", ".join(name.upper() for name in expect.names), ) iter_groups = iter(match.groups()) diff --git a/src/textual/design.py b/src/textual/design.py index ae2a13192..b09fcabb4 100644 --- a/src/textual/design.py +++ b/src/textual/design.py @@ -9,39 +9,18 @@ from rich.text import Text from .color import Color, WHITE + NUMBER_OF_SHADES = 3 # Where no content exists DEFAULT_DARK_BACKGROUND = "#000000" # What text usually goes on top off -DEFAULT_DARK_SURFACE = "#121212" +DEFAULT_DARK_SURFACE = "#292929" DEFAULT_LIGHT_SURFACE = "#f5f5f5" DEFAULT_LIGHT_BACKGROUND = "#efefef" -class ColorProperty: - """Descriptor to parse colors.""" - - def __set_name__(self, owner: ColorSystem, name: str) -> None: - self._name = f"_{name}" - - def __get__( - self, obj: ColorSystem, objtype: type[ColorSystem] | None = None - ) -> Color | None: - color = getattr(obj, self._name) - if color is None: - return None - else: - return Color.parse(color) - - def __set__(self, obj: ColorSystem, value: Color | str | None) -> None: - if isinstance(value, Color): - setattr(obj, self._name, value.css) - else: - setattr(obj, self._name, value) - - class ColorSystem: """Defines a standard set of colors and variations for building a UI. @@ -63,7 +42,6 @@ class ColorSystem: "error", "success", "accent", - "system", ] def __init__( @@ -74,42 +52,30 @@ class ColorSystem: error: str | None = None, success: str | None = None, accent: str | None = None, - system: str | None = None, background: str | None = None, surface: str | None = None, - dark_background: str | None = None, - dark_surface: str | None = None, panel: str | None = None, + dark: bool = False, + luminosity_spread: float = 0.15, + text_alpha: float = 0.95, ): - self._primary = primary - self._secondary = secondary - self._warning = warning - self._error = error - self._success = success - self._accent = accent - self._system = system - self._background = background - self._surface = surface - self._dark_background = dark_background - self._dark_surface = dark_surface - self._panel = panel + def parse(color: str | None) -> Color | None: + if color is None: + return None + return Color.parse(color) - @property - def primary(self) -> Color: - """Get the primary color.""" - return Color.parse(self._primary) - - secondary = ColorProperty() - warning = ColorProperty() - error = ColorProperty() - success = ColorProperty() - accent = ColorProperty() - system = ColorProperty() - surface = ColorProperty() - background = ColorProperty() - dark_surface = ColorProperty() - dark_background = ColorProperty() - panel = ColorProperty() + self.primary = Color.parse(primary) + self.secondary = parse(secondary) + self.warning = parse(warning) + self.error = parse(error) + self.success = parse(success) + self.accent = parse(accent) + self.background = parse(background) + self.surface = parse(surface) + self.panel = parse(panel) + self._dark = dark + self._luminosity_spread = luminosity_spread + self._text_alpha = text_alpha @property def shades(self) -> Iterable[str]: @@ -123,12 +89,7 @@ class ColorSystem: else: yield color - def generate( - self, - dark: bool = False, - luminosity_spread: float = 0.15, - text_alpha: float = 0.9, - ) -> dict[str, str]: + def generate(self) -> dict[str, str]: """Generate a mapping of color name on to a CSS color. Args: @@ -148,22 +109,20 @@ class ColorSystem: error = self.error or secondary success = self.success or secondary accent = self.accent or primary - system = self.system or accent - light_background = self.background or Color.parse(DEFAULT_LIGHT_BACKGROUND) - dark_background = self.dark_background or Color.parse(DEFAULT_DARK_BACKGROUND) + dark = self._dark + luminosity_spread = self._luminosity_spread + text_alpha = self._text_alpha - light_surface = self.surface or Color.parse(DEFAULT_LIGHT_SURFACE) - dark_surface = self.dark_surface or Color.parse(DEFAULT_DARK_SURFACE) + if dark: + background = self.background or Color.parse(DEFAULT_DARK_BACKGROUND) + surface = self.surface or Color.parse(DEFAULT_DARK_SURFACE) + else: + background = self.background or Color.parse(DEFAULT_LIGHT_BACKGROUND) + surface = self.surface or Color.parse(DEFAULT_LIGHT_SURFACE) - background = dark_background if dark else light_background - surface = dark_surface if dark else light_surface - - text = background.get_contrast_text(1.0) if self.panel is None: - panel = background.blend( - text, luminosity_spread if dark else luminosity_spread - ) + panel = surface.blend(primary, luminosity_spread) else: panel = self.panel @@ -199,7 +158,6 @@ class ColorSystem: ("error", error), ("success", success), ("accent", accent), - ("system", system), ] # Colors names that have a dark variant @@ -207,7 +165,7 @@ class ColorSystem: for name, color in COLORS: is_dark_shade = dark and name in DARK_SHADES - spread = luminosity_spread / 1.5 if is_dark_shade else luminosity_spread + spread = luminosity_spread if name == "panel": spread /= 2 for shade_name, luminosity_delta in luminosity_range(spread): @@ -231,27 +189,39 @@ class ColorSystem: return colors - def __rich__(self) -> Table: - @group() - def make_shades(dark: bool): - colors = self.generate(dark) - for name in self.shades: - background = colors[name] - foreground = colors[f"text-{name}"] - text = Text(f"{background} ", style=f"{foreground} on {background}") - for fade in range(3): - foreground = colors[ - f"text-{name}-fade-{fade}" if fade else f"text-{name}" - ] - text.append(f"{name} ", style=f"{foreground} on {background}") - yield Padding(text, 1, style=f"{foreground} on {background}") +def show_design(light: ColorSystem, dark: ColorSystem) -> Table: + """Generate a renderable to show color systems. - table = Table(box=None, expand=True) - table.add_column("Light", justify="center") - table.add_column("Dark", justify="center") - table.add_row(make_shades(False), make_shades(True)) - return table + Args: + light (ColorSystem): Light ColorSystem. + dark (ColorSystem): Dark ColorSystem + + Returns: + Table: Table showing all colors. + + """ + + @group() + def make_shades(system: ColorSystem): + colors = system.generate() + for name in system.shades: + background = colors[name] + foreground = colors[f"text-{name}"] + text = Text(f"{background} ", style=f"{foreground} on {background}") + for fade in range(3): + foreground = colors[ + f"text-{name}-fade-{fade}" if fade else f"text-{name}" + ] + text.append(f"{name} ", style=f"{foreground} on {background}") + + yield Padding(text, 1, style=f"{foreground} on {background}") + + table = Table(box=None, expand=True) + table.add_column("Light", justify="center") + table.add_column("Dark", justify="center") + table.add_row(make_shades(light), make_shades(dark)) + return table if __name__ == "__main__": @@ -259,4 +229,4 @@ if __name__ == "__main__": from rich import print - print(DEFAULT_COLORS) + print(show_design(DEFAULT_COLORS["light"], DEFAULT_COLORS["dark"])) diff --git a/src/textual/widget.py b/src/textual/widget.py index 9909a1469..2e8247fce 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -71,9 +71,9 @@ class Widget(DOMNode): CSS = """ Widget{ - scrollbar-background: $panel-darken-2; - scrollbar-background-hover: $panel-darken-3; - scrollbar-color: $system; + scrollbar-background: $panel-darken-1; + scrollbar-background-hover: $panel-darken-2; + scrollbar-color: $primary-lighten-1; scrollbar-color-active: $warning-darken-1; scrollbar-size-vertical: 2; scrollbar-size-horizontal: 1; diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py index 085c8630e..f1b1dba84 100644 --- a/src/textual/widgets/_button.py +++ b/src/textual/widgets/_button.py @@ -33,11 +33,11 @@ class Button(Widget, can_focus=True): Button { width: auto; height: 3; - background: $primary; - color: $text-primary; + background: $panel; + color: $text-panel; border: none; - border-top: tall $primary-lighten-2; - border-bottom: tall $primary-darken-3; + border-top: tall $panel-lighten-2; + border-bottom: tall $panel-darken-3; content-align: center middle; text-style: bold; } @@ -47,15 +47,15 @@ class Button(Widget, can_focus=True): } Button:hover { - border-top: tall $primary-lighten-1; - background: $primary-darken-2; - color: $text-primary-darken-2; + border-top: tall $panel-lighten-1; + background: $panel-darken-2; + color: $text-panel-darken-2; } Button.-active { - background: $primary; - border-bottom: tall $primary-lighten-2; - border-top: tall $primary-darken-2; + background: $panel; + border-bottom: tall $panel-lighten-2; + border-top: tall $panel-darken-2; } diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 04f14fc2a..1ede166e5 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -113,13 +113,13 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): } DataTable > .datatable--header { text-style: bold; - background: $primary-darken-1; - color: $text-primary-darken-1; + background: $primary; + color: $text-primary; } DataTable > .datatable--fixed { text-style: bold; - background: $primary-darken-2; - color: $text-primary-darken-2; + background: $primary; + color: $text-primary; } DataTable > .datatable--odd-row { diff --git a/tests/css/test_stylesheet.py b/tests/css/test_stylesheet.py index 14b912f00..ac732f6f7 100644 --- a/tests/css/test_stylesheet.py +++ b/tests/css/test_stylesheet.py @@ -6,7 +6,7 @@ import pytest from textual.color import Color from textual.css._help_renderables import HelpText from textual.css.stylesheet import Stylesheet, StylesheetParseError, CssSource -from textual.css.tokenizer import TokenizeError +from textual.css.tokenizer import TokenError from textual.dom import DOMNode from textual.geometry import Spacing from textual.widget import Widget @@ -145,7 +145,7 @@ def test_stylesheet_apply_user_css_over_widget_css(): ["ansi_dark_cyan", pytest.raises(StylesheetParseError), None], ["red 4", pytest.raises(StylesheetParseError), None], # space in it ["1", pytest.raises(StylesheetParseError), None], # invalid value - ["()", pytest.raises(TokenizeError), None], # invalid tokens + ["()", pytest.raises(TokenError), None], # invalid tokens ], ) def test_color_property_parsing(css_value, expectation, expected_color): diff --git a/tests/css/test_tokenize.py b/tests/css/test_tokenize.py index ff6565dc0..1e8593c2c 100644 --- a/tests/css/test_tokenize.py +++ b/tests/css/test_tokenize.py @@ -3,7 +3,7 @@ from __future__ import annotations import pytest from textual.css.tokenize import tokenize -from textual.css.tokenizer import Token, TokenizeError +from textual.css.tokenizer import Token, TokenError VALID_VARIABLE_NAMES = [ "warning-text", @@ -331,7 +331,7 @@ def test_variable_declaration_no_semicolon(): def test_variable_declaration_invalid_value(): css = "$x:(@$12x)" - with pytest.raises(TokenizeError): + with pytest.raises(TokenError): list(tokenize(css, ""))