From 9e5bc82336e017e016645c746983c88e8e58e9fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Tue, 24 Oct 2023 10:51:15 +0100 Subject: [PATCH 01/18] Remove unused function. --- src/textual/css/_styles_builder.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/src/textual/css/_styles_builder.py b/src/textual/css/_styles_builder.py index 7f375dfaa6..06da1ca5d0 100644 --- a/src/textual/css/_styles_builder.py +++ b/src/textual/css/_styles_builder.py @@ -65,19 +65,6 @@ from .types import BoxSizing, Display, EdgeType, Overflow, Visibility -def _join_tokens(tokens: Iterable[Token], joiner: str = "") -> str: - """Convert tokens into a string by joining their values - - Args: - tokens: Tokens to join - joiner: String to join on. - - Returns: - The tokens, joined together to form a string. - """ - return joiner.join(token.value for token in tokens) - - class StylesBuilder: """ The StylesBuilder object takes tokens parsed from the CSS and converts From 2918011512d8ca1451472769b5f1ca82d1614611 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Tue, 24 Oct 2023 10:52:05 +0100 Subject: [PATCH 02/18] Fix link in CSS error reporting (#3569). --- src/textual/css/stylesheet.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index 3362520293..ffaf1288fb 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -69,10 +69,13 @@ def __rich_console__( error_count += 1 if token.path: - path = Path(token.path) - filename = path.name + # The display path may end with a ":SomeWidget". + display_path = Path(token.path).absolute() + link_path = str(display_path).split(":")[0] + filename = display_path.name else: - path = None + display_path = "" + link_path = "" filename = "" if token.referenced_by: @@ -80,13 +83,14 @@ def __rich_console__( else: line_idx, col_idx = token.location line_no, col_no = line_idx + 1, col_idx + 1 - path_string = f"{path.absolute() if path else filename}:{line_no}:{col_no}" + path_string = f"{display_path or filename}:{line_no}:{col_no}" link_style = Style( - link=f"file://{path.absolute()}" if path else None, + link=f"file://{link_path}" if link_path else None, color="red", bold=True, italic=True, ) + path_text = Text(path_string, style=link_style) title = Text.assemble(Text("Error at ", style="bold red"), path_text) yield "" From 9076f41ce38e77788f744ee1a8364317b4b843cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Tue, 24 Oct 2023 17:16:58 +0100 Subject: [PATCH 03/18] Keep track of what widget/class variable CSS is read from. We already kept track of the file and widget CSS was read from. Now, we also keep track of the class variable it comes from and we create some structure to transfer that information across the program. --- src/textual/app.py | 26 +++++----- src/textual/css/parse.py | 23 ++++----- src/textual/css/query.py | 2 +- src/textual/css/styles.py | 9 ++-- src/textual/css/stylesheet.py | 95 +++++++++++++++++++++++------------ src/textual/css/tokenize.py | 17 ++++--- src/textual/css/tokenizer.py | 49 +++++++++++------- src/textual/css/types.py | 9 ++++ src/textual/dom.py | 30 +++++++---- src/textual/widget.py | 4 +- 10 files changed, 163 insertions(+), 101 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 0da2209a09..b004db1b89 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1753,20 +1753,19 @@ def _load_screen_css(self, screen: Screen): update = False for path in screen.css_path: - if not self.stylesheet.has_source(path): + if not self.stylesheet.has_source((str(path), "")): self.stylesheet.read(path) update = True if screen.CSS: try: - screen_css_path = ( - f"{inspect.getfile(screen.__class__)}:{screen.__class__.__name__}" - ) + screen_path = inspect.getfile(screen.__class__) except (TypeError, OSError): - screen_css_path = f"{screen.__class__.__name__}" - if not self.stylesheet.has_source(screen_css_path): + screen_path = "" + read_from = (screen_path, f"{screen.__class__.__name__}.CSS") + if not self.stylesheet.has_source(read_from): self.stylesheet.add_source( screen.CSS, - path=screen_css_path, + read_from=read_from, is_default_css=False, scope=screen._css_type_name if screen.SCOPED_CSS else "", ) @@ -2127,23 +2126,22 @@ async def _process_messages( try: if self.css_path: self.stylesheet.read_all(self.css_path) - for path, css, tie_breaker, scope in self._get_default_css(): + for read_from, css, tie_breaker, scope in self._get_default_css(): self.stylesheet.add_source( css, - path=path, + read_from=read_from, is_default_css=True, tie_breaker=tie_breaker, scope=scope, ) if self.CSS: try: - app_css_path = ( - f"{inspect.getfile(self.__class__)}:{self.__class__.__name__}" - ) + app_path = inspect.getfile(self.__class__) except (TypeError, OSError): - app_css_path = f"{self.__class__.__name__}" + app_path = "" + read_from = (app_path, f"{self.__class__.__name__}.CSS") self.stylesheet.add_source( - self.CSS, path=app_css_path, is_default_css=False + self.CSS, read_from=read_from, is_default_css=False ) except Exception as error: self._handle_exception(error) diff --git a/src/textual/css/parse.py b/src/textual/css/parse.py index a9ee06d15a..9be5047f30 100644 --- a/src/textual/css/parse.py +++ b/src/textual/css/parse.py @@ -1,7 +1,6 @@ from __future__ import annotations from functools import lru_cache -from pathlib import PurePath from typing import Iterable, Iterator, NoReturn from ..suggestions import get_suggestion @@ -19,7 +18,7 @@ from .styles import Styles from .tokenize import Token, tokenize, tokenize_declarations, tokenize_values from .tokenizer import EOFError, ReferencedBy -from .types import Specificity3 +from .types import CSSLocation, Specificity3 SELECTOR_MAP: dict[str, tuple[SelectorType, Specificity3]] = { "selector": (SelectorType.TYPE, (0, 0, 1)), @@ -38,7 +37,7 @@ def parse_selectors(css_selectors: str) -> tuple[SelectorSet, ...]: if not css_selectors.strip(): return () - tokens = iter(tokenize(css_selectors, "")) + tokens = iter(tokenize(css_selectors, ("", ""))) get_selector = SELECTOR_MAP.get combinator: CombinatorType | None = CombinatorType.DESCENDENT @@ -180,18 +179,18 @@ def parse_rule_set( yield rule_set -def parse_declarations(css: str, path: str) -> Styles: +def parse_declarations(css: str, read_from: CSSLocation) -> Styles: """Parse declarations and return a Styles object. Args: css: String containing CSS. - path: Path to the CSS, or something else to identify the location. + read_from: The location where the CSS was read from. Returns: A styles object. """ - tokens = iter(tokenize_declarations(css, path)) + tokens = iter(tokenize_declarations(css, read_from)) styles_builder = StylesBuilder() declaration: Declaration | None = None @@ -245,7 +244,7 @@ def _unresolved(variable_name: str, variables: Iterable[str], token: Token) -> N message += f"; did you mean '${suggested_variable}'?" raise UnresolvedVariableError( - token.path, + token.read_from, token.code, token.start, message, @@ -341,7 +340,7 @@ def substitute_references( def parse( scope: str, css: str, - path: str | PurePath, + read_from: CSSLocation, variables: dict[str, str] | None = None, variable_tokens: dict[str, list[Token]] | None = None, is_default_rules: bool = False, @@ -351,9 +350,9 @@ def parse( and generating rule sets from it. Args: - scope: CSS type name - css: The input CSS - path: Path to the CSS + scope: CSS type name. + css: The input CSS. + read_from: The source location of the CSS. variables: Substitution variables to substitute tokens for. is_default_rules: True if the rules we're extracting are default (i.e. in Widget.DEFAULT_CSS) rules. False if they're from user defined CSS. @@ -363,7 +362,7 @@ def parse( if variable_tokens: reference_tokens.update(variable_tokens) - tokens = iter(substitute_references(tokenize(css, path), variable_tokens)) + tokens = iter(substitute_references(tokenize(css, read_from), variable_tokens)) while True: token = next(tokens, None) if token is None: diff --git a/src/textual/css/query.py b/src/textual/css/query.py index a6af6ae679..7df103e7a3 100644 --- a/src/textual/css/query.py +++ b/src/textual/css/query.py @@ -407,7 +407,7 @@ def set_styles( node.set_styles(**update_styles) if css is not None: try: - new_styles = parse_declarations(css, path="set_styles") + new_styles = parse_declarations(css, read_from=("set_styles", "")) except DeclarationError as error: raise DeclarationError(error.name, error.token, error.message) from None for node in self: diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 03f9448529..b6b6cdd3f3 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -69,6 +69,7 @@ if TYPE_CHECKING: from .._layout import Layout from ..dom import DOMNode + from .types import CSSLocation class RulesMap(TypedDict, total=False): @@ -534,12 +535,14 @@ def is_animatable(cls, rule: str) -> bool: @classmethod @lru_cache(maxsize=1024) - def parse(cls, css: str, path: str, *, node: DOMNode | None = None) -> Styles: + def parse( + cls, css: str, read_from: CSSLocation, *, node: DOMNode | None = None + ) -> Styles: """Parse CSS and return a Styles object. Args: css: Textual CSS. - path: Path or string indicating source of CSS. + read_from: Location where the CSS was read from. node: Node to associate with the Styles. Returns: @@ -547,7 +550,7 @@ def parse(cls, css: str, path: str, *, node: DOMNode | None = None) -> Styles: """ from .parse import parse_declarations - styles = parse_declarations(css, path) + styles = parse_declarations(css, read_from) styles.node = node return styles diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index ffaf1288fb..2af8857bde 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -26,7 +26,7 @@ from .styles import RulesMap, Styles from .tokenize import Token, tokenize_values from .tokenizer import TokenError -from .types import Specificity3, Specificity6 +from .types import CSSLocation, Specificity3, Specificity6 _DEFAULT_STYLES = Styles() @@ -68,14 +68,12 @@ def __rich_console__( for token, message in errors: error_count += 1 - if token.path: - # The display path may end with a ":SomeWidget". - display_path = Path(token.path).absolute() - link_path = str(display_path).split(":")[0] - filename = display_path.name + if token.read_from: + display_path, widget_var = token.read_from + link_path = str(Path(display_path).absolute()) + filename = Path(link_path).name else: - display_path = "" - link_path = "" + link_path = display_path = widget_var = "" filename = "" if token.referenced_by: @@ -83,16 +81,39 @@ def __rich_console__( else: line_idx, col_idx = token.location line_no, col_no = line_idx + 1, col_idx + 1 - path_string = f"{display_path or filename}:{line_no}:{col_no}" + + if widget_var: + path_string = ( + f"{link_path or filename} in {widget_var}:{line_no}:{col_no}" + ) + else: + path_string = f"{link_path or filename}:{line_no}:{col_no}" + + # If we have a widget/variable from where the CSS was read, then line/column + # numbers are relative to the inline CSS and we'll display them next to the + # widget/variable. + # Otherwise, they're absolute positions in a TCSS file and we can show them + # next to the file path. + if widget_var: + path_string = link_path or filename + widget_text = Text( + f" in {widget_var}:{line_no}:{col_no}", style="bold red" + ) + else: + path_string = f"{link_path or filename}:{line_no}:{col_no}" + widget_text = Text() + link_style = Style( link=f"file://{link_path}" if link_path else None, color="red", bold=True, italic=True, ) - path_text = Text(path_string, style=link_style) - title = Text.assemble(Text("Error at ", style="bold red"), path_text) + + title = Text.assemble( + Text("Error at ", style="bold red"), path_text, widget_text + ) yield "" yield Panel( self._get_snippet( @@ -136,7 +157,7 @@ def __init__(self, *, variables: dict[str, str] | None = None) -> None: self._rules_map: dict[str, list[RuleSet]] | None = None self._variables = variables or {} self.__variable_tokens: dict[str, list[Token]] | None = None - self.source: dict[str, CssSource] = {} + self.source: dict[CSSLocation, CssSource] = {} self._require_parse = False self._invalid_css: set[str] = set() self._parse_cache: LRUCache[tuple, list[RuleSet]] = LRUCache(64) @@ -206,7 +227,7 @@ def set_variables(self, variables: dict[str, str]) -> None: def _parse_rules( self, css: str, - path: str | PurePath, + read_from: CSSLocation, is_default_rules: bool = False, tie_breaker: int = 0, scope: str = "", @@ -215,7 +236,7 @@ def _parse_rules( Args: css: String containing Textual CSS. - path: Path to CSS or unique identifier + read_from: Original CSS location. is_default_rules: True if the rules we're extracting are default (i.e. in Widget.DEFAULT_CSS) rules. False if they're from user defined CSS. scope: Scope of rules, or empty string for global scope. @@ -226,7 +247,7 @@ def _parse_rules( Returns: List of RuleSets. """ - cache_key = (css, path, is_default_rules, tie_breaker, scope) + cache_key = (css, read_from, is_default_rules, tie_breaker, scope) try: return self._parse_cache[cache_key] except KeyError: @@ -236,7 +257,7 @@ def _parse_rules( parse( scope, css, - path, + read_from, variable_tokens=self._variable_tokens, is_default_rules=is_default_rules, tie_breaker=tie_breaker, @@ -267,7 +288,7 @@ def read(self, filename: str | PurePath) -> None: path = os.path.abspath(filename) except Exception: raise StylesheetError(f"unable to read CSS file {filename!r}") from None - self.source[str(path)] = CssSource(css, False, 0) + self.source[(str(path), "")] = CssSource(css, False, 0) self._require_parse = True def read_all(self, paths: Sequence[PurePath]) -> None: @@ -283,18 +304,18 @@ def read_all(self, paths: Sequence[PurePath]) -> None: for path in paths: self.read(path) - def has_source(self, path: str | PurePath) -> bool: + def has_source(self, read_from: CSSLocation) -> bool: """Check if the stylesheet has this CSS source already. Returns: Whether the stylesheet is aware of this CSS source or not. """ - return str(path) in self.source + return read_from in self.source def add_source( self, css: str, - path: str | PurePath | None = None, + read_from: CSSLocation | None = None, is_default_css: bool = False, tie_breaker: int = 0, scope: str = "", @@ -303,6 +324,8 @@ def add_source( Args: css: String with CSS source. + location: The original location of the CSS as a pair of file path and class + variable (for the case where the CSS comes from a Python source file). path: The path of the source if a file, or some other identifier. is_default_css: True if the CSS is defined in the Widget, False if the CSS is defined in a user stylesheet. @@ -314,17 +337,18 @@ def add_source( StylesheetParseError: If the CSS is invalid. """ - if path is None: - path = str(hash(css)) - elif isinstance(path, PurePath): - path = str(css) - if path in self.source and self.source[path].content == css: - # Path already in source, and CSS is identical - content, is_defaults, source_tie_breaker, scope = self.source[path] + if read_from is None: + read_from = ("", str(hash(css))) + + if read_from in self.source and self.source[read_from].content == css: + # Location already in source and CSS is identical. + content, is_defaults, source_tie_breaker, scope = self.source[read_from] if source_tie_breaker > tie_breaker: - self.source[path] = CssSource(content, is_defaults, tie_breaker, scope) + self.source[read_from] = CssSource( + content, is_defaults, tie_breaker, scope + ) return - self.source[path] = CssSource(css, is_default_css, tie_breaker, scope) + self.source[read_from] = CssSource(css, is_default_css, tie_breaker, scope) self._require_parse = True def parse(self) -> None: @@ -336,13 +360,18 @@ def parse(self) -> None: rules: list[RuleSet] = [] add_rules = rules.extend - for path, (css, is_default_rules, tie_breaker, scope) in self.source.items(): + for read_from, ( + css, + is_default_rules, + tie_breaker, + scope, + ) in self.source.items(): if css in self._invalid_css: continue try: css_rules = self._parse_rules( css, - path, + read_from=read_from, is_default_rules=is_default_rules, tie_breaker=tie_breaker, scope=scope, @@ -368,10 +397,10 @@ def reparse(self) -> None: """ # Do this in a fresh Stylesheet so if there are errors we don't break self. stylesheet = Stylesheet(variables=self._variables) - for path, (css, is_defaults, tie_breaker, scope) in self.source.items(): + for read_from, (css, is_defaults, tie_breaker, scope) in self.source.items(): stylesheet.add_source( css, - path, + read_from=read_from, is_default_css=is_defaults, tie_breaker=tie_breaker, scope=scope, diff --git a/src/textual/css/tokenize.py b/src/textual/css/tokenize.py index 12e7ed6e26..de9f80787a 100644 --- a/src/textual/css/tokenize.py +++ b/src/textual/css/tokenize.py @@ -1,10 +1,12 @@ from __future__ import annotations import re -from pathlib import PurePath -from typing import Iterable +from typing import TYPE_CHECKING, Iterable -from textual.css.tokenizer import Expect, Token, Tokenizer +from .tokenizer import Expect, Token, Tokenizer + +if TYPE_CHECKING: + from .types import CSSLocation PERCENT = r"-?\d+\.?\d*%" DECIMAL = r"-?\d+\.?\d*" @@ -157,8 +159,8 @@ class TokenizerState: "declaration_set_end": expect_root_scope, } - def __call__(self, code: str, path: str | PurePath) -> Iterable[Token]: - tokenizer = Tokenizer(code, path=path) + def __call__(self, code: str, read_from: CSSLocation) -> Iterable[Token]: + tokenizer = Tokenizer(code, read_from=read_from) expect = self.EXPECT get_token = tokenizer.get_token get_state = self.STATE_MAP.get @@ -194,7 +196,7 @@ class ValueTokenizerState(TokenizerState): def tokenize_values(values: dict[str, str]) -> dict[str, list[Token]]: - """Tokens the values in a dict of strings. + """Tokenizes the values in a dict of strings. Args: values: A mapping of CSS variable name on to a value, to be @@ -204,6 +206,7 @@ def tokenize_values(values: dict[str, str]) -> dict[str, list[Token]]: A mapping of name on to a list of tokens, """ value_tokens = { - name: list(tokenize_value(value, "__name__")) for name, value in values.items() + name: list(tokenize_value(value, ("__name__", ""))) + for name, value in values.items() } return value_tokens diff --git a/src/textual/css/tokenizer.py b/src/textual/css/tokenizer.py index 276b22b54b..d7cfc449d3 100644 --- a/src/textual/css/tokenizer.py +++ b/src/textual/css/tokenizer.py @@ -1,8 +1,7 @@ from __future__ import annotations import re -from pathlib import PurePath -from typing import NamedTuple +from typing import TYPE_CHECKING, NamedTuple import rich.repr from rich.console import Group, RenderableType @@ -16,13 +15,16 @@ from ._error_tools import friendly_list from .constants import VALID_PSEUDO_CLASSES +if TYPE_CHECKING: + from .types import CSSLocation + class TokenError(Exception): """Error raised when the CSS cannot be tokenized (syntax error).""" def __init__( self, - path: str, + read_from: CSSLocation, code: str, start: tuple[int, int], message: str, @@ -30,14 +32,14 @@ def __init__( ) -> None: """ Args: - path: Path to source or "" if source is parsed from a literal. + read_from: The location where the CSS was read from. code: The code being parsed. start: Line number of the error. message: A message associated with the error. end: End location of token, or None if not known. """ - self.path = path + self.read_from = read_from self.code = code self.start = start self.end = end or start @@ -72,7 +74,12 @@ def __rich__(self) -> RenderableType: line_no, col_no = self.start - errors.append(highlighter(f" {self.path or ''}:{line_no}:{col_no}")) + path, widget_variable = self.read_from + if widget_variable: + css_location = f" {path}, {widget_variable}:{line_no}:{col_no}" + else: + css_location = f" {path}:{line_no}:{col_no}" + errors.append(highlighter(css_location)) errors.append(self._get_snippet()) final_message = "\n".join( @@ -126,7 +133,7 @@ class ReferencedBy(NamedTuple): class Token(NamedTuple): name: str value: str - path: str + read_from: CSSLocation code: str location: tuple[int, int] referenced_by: ReferencedBy | None = None @@ -153,7 +160,7 @@ def with_reference(self, by: ReferencedBy | None) -> "Token": return Token( name=self.name, value=self.value, - path=self.path, + read_from=self.read_from, code=self.code, location=self.location, referenced_by=by, @@ -165,15 +172,18 @@ def __str__(self) -> str: def __rich_repr__(self) -> rich.repr.Result: yield "name", self.name yield "value", self.value - yield "path", self.path + yield ( + "read_from", + self.read_from[0] if not self.read_from[1] else self.read_from, + ) yield "code", self.code if len(self.code) < 40 else self.code[:40] + "..." yield "location", self.location yield "referenced_by", self.referenced_by, None class Tokenizer: - def __init__(self, text: str, path: str | PurePath = "") -> None: - self.path = str(path) + def __init__(self, text: str, read_from: CSSLocation = ("", "")) -> None: + self.read_from = read_from self.code = text self.lines = text.splitlines(keepends=True) self.line_no = 0 @@ -187,14 +197,14 @@ def get_token(self, expect: Expect) -> Token: return Token( "eof", "", - self.path, + self.read_from, self.code, (line_no + 1, col_no + 1), None, ) else: raise EOFError( - self.path, + self.read_from, self.code, (line_no + 1, col_no + 1), "Unexpected end of file", @@ -205,7 +215,7 @@ def get_token(self, expect: Expect) -> Token: expected = friendly_list(" ".join(name.split("_")) for name in expect.names) message = f"Expected one of {expected}.; Did you forget a semicolon at the end of a line?" raise TokenError( - self.path, + self.read_from, self.code, (line_no, col_no), message, @@ -224,7 +234,7 @@ def get_token(self, expect: Expect) -> Token: token = Token( name, value, - self.path, + self.read_from, self.code, (line_no, col_no), referenced_by=None, @@ -239,14 +249,14 @@ def get_token(self, expect: Expect) -> Token: all_valid = f"must be one of {friendly_list(VALID_PSEUDO_CLASSES)}" if suggestion: raise TokenError( - self.path, + self.read_from, self.code, (line_no, col_no), f"unknown pseudo-class {pseudo_class!r}; did you mean {suggestion!r}?; {all_valid}", ) else: raise TokenError( - self.path, + self.read_from, self.code, (line_no, col_no), f"unknown pseudo-class {pseudo_class!r}; {all_valid}", @@ -267,7 +277,10 @@ def skip_to(self, expect: Expect) -> Token: while True: if line_no >= len(self.lines): raise EOFError( - self.path, self.code, (line_no, col_no), "Unexpected end of file" + self.read_from, + self.code, + (line_no, col_no), + "Unexpected end of file", ) line = self.lines[line_no] match = expect.search(line, col_no) diff --git a/src/textual/css/types.py b/src/textual/css/types.py index c723e0b5fa..ce4cebdd0b 100644 --- a/src/textual/css/types.py +++ b/src/textual/css/types.py @@ -42,3 +42,12 @@ Specificity3 = Tuple[int, int, int] Specificity6 = Tuple[int, int, int, int, int, int] + +CSSLocation = Tuple[str, str] +"""Represents the definition location of a piece of CSS code. + +The first element of the tuple is the file path from where the CSS was read. +If the CSS was read from a Python source file, the second element contains the class +variable from where the CSS was read (e.g., "Widget.DEFAULT_CSS"), otherwise it's an +empty string. +""" diff --git a/src/textual/dom.py b/src/textual/dom.py index bc8ab776bf..c380c6f4aa 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -416,33 +416,41 @@ def __rich_repr__(self) -> rich.repr.Result: if hasattr(self, "_classes") and self._classes: yield "classes", " ".join(self._classes) - def _get_default_css(self) -> list[tuple[str, str, int, str]]: + def _get_default_css(self) -> list[tuple[tuple[str, str], str, int, str]]: """Gets the CSS for this class and inherited from bases. Default CSS is inherited from base classes, unless `inherit_css` is set to `False` when subclassing. Returns: - A list of tuples containing (PATH, SOURCE, SPECIFICITY, SCOPE) for this - and inherited from base classes. + A list of tuples containing (LOCATION, SOURCE, SPECIFICITY, SCOPE) for this + class and inherited from base classes. """ - css_stack: list[tuple[str, str, int, str]] = [] + css_stack: list[tuple[tuple[str, str], str, int, str]] = [] - def get_path(base: Type[DOMNode]) -> str: - """Get a path to the DOM Node""" + def get_location(base: Type[DOMNode]) -> tuple[str, str]: + """Get the original location of this DEFAULT_CSS. + + Args: + base: The class from which the default css was extracted. + + Returns: + The filename where the class was defined (if possible) and the class + variable the CSS was extracted from. + """ try: - return f"{getfile(base)}:{base.__name__}" + return (getfile(base), f"{base.__name__}.DEFAULT_CSS") except (TypeError, OSError): - return f"{base.__name__}" + return ("", f"{base.__name__}.DEFAULT_CSS") for tie_breaker, base in enumerate(self._node_bases): - css: str = base.__dict__.get("DEFAULT_CSS", "").strip() + css: str = base.__dict__.get("DEFAULT_CSS", "") if css: scoped: bool = base.__dict__.get("SCOPED_CSS", True) css_stack.append( ( - get_path(base), + get_location(base), css, -tie_breaker, base._css_type_name if scoped else "", @@ -1136,7 +1144,7 @@ def set_styles(self, css: str | None = None, **update_styles) -> Self: if css is not None: try: - new_styles = parse_declarations(css, path="set_styles") + new_styles = parse_declarations(css, read_from=("set_styles", "")) except DeclarationError as error: raise DeclarationError(error.name, error.token, error.message) from None self._inline_styles.merge(new_styles) diff --git a/src/textual/widget.py b/src/textual/widget.py index 6960af7a2e..f9df5cba62 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -941,10 +941,10 @@ def _post_register(self, app: App) -> None: app: App instance. """ # Parse the Widget's CSS - for path, css, tie_breaker, scope in self._get_default_css(): + for read_from, css, tie_breaker, scope in self._get_default_css(): self.app.stylesheet.add_source( css, - path=path, + read_from=read_from, is_default_css=True, tie_breaker=tie_breaker, scope=scope, From 55fe9d3891228fa55f13f9f1b118ebbebfc37561 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Tue, 24 Oct 2023 17:51:34 +0100 Subject: [PATCH 04/18] Fix tests. --- tests/css/test_parse.py | 218 +++++++++++++++++----------------- tests/css/test_styles.py | 2 +- tests/css/test_tokenize.py | 232 ++++++++++++++++++------------------- 3 files changed, 226 insertions(+), 226 deletions(-) diff --git a/tests/css/test_parse.py b/tests/css/test_parse.py index 6f31bf0946..124f820d53 100644 --- a/tests/css/test_parse.py +++ b/tests/css/test_parse.py @@ -17,12 +17,12 @@ class TestVariableReferenceSubstitution: def test_simple_reference(self): css = "$x: 1; #some-widget{border: $x;}" - variables = substitute_references(tokenize(css, "")) + variables = substitute_references(tokenize(css, ("", ""))) assert list(variables) == [ Token( name="variable_name", value="$x:", - path="", + read_from=("", ""), code=css, location=(0, 0), referenced_by=None, @@ -30,7 +30,7 @@ def test_simple_reference(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 3), referenced_by=None, @@ -38,7 +38,7 @@ def test_simple_reference(self): Token( name="number", value="1", - path="", + read_from=("", ""), code=css, location=(0, 4), referenced_by=None, @@ -46,7 +46,7 @@ def test_simple_reference(self): Token( name="variable_value_end", value=";", - path="", + read_from=("", ""), code=css, location=(0, 5), referenced_by=None, @@ -54,7 +54,7 @@ def test_simple_reference(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 6), referenced_by=None, @@ -62,7 +62,7 @@ def test_simple_reference(self): Token( name="selector_start_id", value="#some-widget", - path="", + read_from=("", ""), code=css, location=(0, 7), referenced_by=None, @@ -70,7 +70,7 @@ def test_simple_reference(self): Token( name="declaration_set_start", value="{", - path="", + read_from=("", ""), code=css, location=(0, 19), referenced_by=None, @@ -78,7 +78,7 @@ def test_simple_reference(self): Token( name="declaration_name", value="border:", - path="", + read_from=("", ""), code=css, location=(0, 20), referenced_by=None, @@ -86,7 +86,7 @@ def test_simple_reference(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 27), referenced_by=None, @@ -94,7 +94,7 @@ def test_simple_reference(self): Token( name="number", value="1", - path="", + read_from=("", ""), code=css, location=(0, 4), referenced_by=ReferencedBy( @@ -104,7 +104,7 @@ def test_simple_reference(self): Token( name="declaration_end", value=";", - path="", + read_from=("", ""), code=css, location=(0, 30), referenced_by=None, @@ -112,7 +112,7 @@ def test_simple_reference(self): Token( name="declaration_set_end", value="}", - path="", + read_from=("", ""), code=css, location=(0, 31), referenced_by=None, @@ -121,12 +121,12 @@ def test_simple_reference(self): def test_simple_reference_no_whitespace(self): css = "$x:1; #some-widget{border: $x;}" - variables = substitute_references(tokenize(css, "")) + variables = substitute_references(tokenize(css, ("", ""))) assert list(variables) == [ Token( name="variable_name", value="$x:", - path="", + read_from=("", ""), code=css, location=(0, 0), referenced_by=None, @@ -134,7 +134,7 @@ def test_simple_reference_no_whitespace(self): Token( name="number", value="1", - path="", + read_from=("", ""), code=css, location=(0, 3), referenced_by=None, @@ -142,7 +142,7 @@ def test_simple_reference_no_whitespace(self): Token( name="variable_value_end", value=";", - path="", + read_from=("", ""), code=css, location=(0, 4), referenced_by=None, @@ -150,7 +150,7 @@ def test_simple_reference_no_whitespace(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 5), referenced_by=None, @@ -158,7 +158,7 @@ def test_simple_reference_no_whitespace(self): Token( name="selector_start_id", value="#some-widget", - path="", + read_from=("", ""), code=css, location=(0, 6), referenced_by=None, @@ -166,7 +166,7 @@ def test_simple_reference_no_whitespace(self): Token( name="declaration_set_start", value="{", - path="", + read_from=("", ""), code=css, location=(0, 18), referenced_by=None, @@ -174,7 +174,7 @@ def test_simple_reference_no_whitespace(self): Token( name="declaration_name", value="border:", - path="", + read_from=("", ""), code=css, location=(0, 19), referenced_by=None, @@ -182,7 +182,7 @@ def test_simple_reference_no_whitespace(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 26), referenced_by=None, @@ -190,7 +190,7 @@ def test_simple_reference_no_whitespace(self): Token( name="number", value="1", - path="", + read_from=("", ""), code=css, location=(0, 3), referenced_by=ReferencedBy( @@ -200,7 +200,7 @@ def test_simple_reference_no_whitespace(self): Token( name="declaration_end", value=";", - path="", + read_from=("", ""), code=css, location=(0, 29), referenced_by=None, @@ -208,7 +208,7 @@ def test_simple_reference_no_whitespace(self): Token( name="declaration_set_end", value="}", - path="", + read_from=("", ""), code=css, location=(0, 30), referenced_by=None, @@ -218,11 +218,11 @@ def test_simple_reference_no_whitespace(self): def test_undefined_variable(self): css = ".thing { border: $not-defined; }" with pytest.raises(UnresolvedVariableError): - list(substitute_references(tokenize(css, ""))) + list(substitute_references(tokenize(css, ("", "")))) def test_empty_variable(self): css = "$x:\n* { background:$x; }" - result = list(substitute_references(tokenize(css, ""))) + result = list(substitute_references(tokenize(css, ("", "")))) assert [(t.name, t.value) for t in result] == [ ("variable_name", "$x:"), ("variable_value_end", "\n"), @@ -238,11 +238,11 @@ def test_empty_variable(self): def test_transitive_reference(self): css = "$x: 1\n$y: $x\n.thing { border: $y }" - assert list(substitute_references(tokenize(css, ""))) == [ + assert list(substitute_references(tokenize(css, ("", "")))) == [ Token( name="variable_name", value="$x:", - path="", + read_from=("", ""), code=css, location=(0, 0), referenced_by=None, @@ -250,7 +250,7 @@ def test_transitive_reference(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 3), referenced_by=None, @@ -258,7 +258,7 @@ def test_transitive_reference(self): Token( name="number", value="1", - path="", + read_from=("", ""), code=css, location=(0, 4), referenced_by=None, @@ -266,7 +266,7 @@ def test_transitive_reference(self): Token( name="variable_value_end", value="\n", - path="", + read_from=("", ""), code=css, location=(0, 5), referenced_by=None, @@ -274,7 +274,7 @@ def test_transitive_reference(self): Token( name="variable_name", value="$y:", - path="", + read_from=("", ""), code=css, location=(1, 0), referenced_by=None, @@ -282,7 +282,7 @@ def test_transitive_reference(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(1, 3), referenced_by=None, @@ -290,7 +290,7 @@ def test_transitive_reference(self): Token( name="number", value="1", - path="", + read_from=("", ""), code=css, location=(0, 4), referenced_by=ReferencedBy( @@ -300,7 +300,7 @@ def test_transitive_reference(self): Token( name="variable_value_end", value="\n", - path="", + read_from=("", ""), code=css, location=(1, 6), referenced_by=None, @@ -308,7 +308,7 @@ def test_transitive_reference(self): Token( name="selector_start_class", value=".thing", - path="", + read_from=("", ""), code=css, location=(2, 0), referenced_by=None, @@ -316,7 +316,7 @@ def test_transitive_reference(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(2, 6), referenced_by=None, @@ -324,7 +324,7 @@ def test_transitive_reference(self): Token( name="declaration_set_start", value="{", - path="", + read_from=("", ""), code=css, location=(2, 7), referenced_by=None, @@ -332,7 +332,7 @@ def test_transitive_reference(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(2, 8), referenced_by=None, @@ -340,7 +340,7 @@ def test_transitive_reference(self): Token( name="declaration_name", value="border:", - path="", + read_from=("", ""), code=css, location=(2, 9), referenced_by=None, @@ -348,7 +348,7 @@ def test_transitive_reference(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(2, 16), referenced_by=None, @@ -356,7 +356,7 @@ def test_transitive_reference(self): Token( name="number", value="1", - path="", + read_from=("", ""), code=css, location=(0, 4), referenced_by=ReferencedBy( @@ -366,7 +366,7 @@ def test_transitive_reference(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(2, 19), referenced_by=None, @@ -374,7 +374,7 @@ def test_transitive_reference(self): Token( name="declaration_set_end", value="}", - path="", + read_from=("", ""), code=css, location=(2, 20), referenced_by=None, @@ -383,11 +383,11 @@ def test_transitive_reference(self): def test_multi_value_variable(self): css = "$x: 2 4\n$y: 6 $x 2\n.thing { border: $y }" - assert list(substitute_references(tokenize(css, ""))) == [ + assert list(substitute_references(tokenize(css, ("", "")))) == [ Token( name="variable_name", value="$x:", - path="", + read_from=("", ""), code=css, location=(0, 0), referenced_by=None, @@ -395,7 +395,7 @@ def test_multi_value_variable(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 3), referenced_by=None, @@ -403,7 +403,7 @@ def test_multi_value_variable(self): Token( name="number", value="2", - path="", + read_from=("", ""), code=css, location=(0, 4), referenced_by=None, @@ -411,7 +411,7 @@ def test_multi_value_variable(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 5), referenced_by=None, @@ -419,7 +419,7 @@ def test_multi_value_variable(self): Token( name="number", value="4", - path="", + read_from=("", ""), code=css, location=(0, 6), referenced_by=None, @@ -427,7 +427,7 @@ def test_multi_value_variable(self): Token( name="variable_value_end", value="\n", - path="", + read_from=("", ""), code=css, location=(0, 7), referenced_by=None, @@ -435,7 +435,7 @@ def test_multi_value_variable(self): Token( name="variable_name", value="$y:", - path="", + read_from=("", ""), code=css, location=(1, 0), referenced_by=None, @@ -443,7 +443,7 @@ def test_multi_value_variable(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(1, 3), referenced_by=None, @@ -451,7 +451,7 @@ def test_multi_value_variable(self): Token( name="number", value="6", - path="", + read_from=("", ""), code=css, location=(1, 4), referenced_by=None, @@ -459,7 +459,7 @@ def test_multi_value_variable(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(1, 5), referenced_by=None, @@ -467,7 +467,7 @@ def test_multi_value_variable(self): Token( name="number", value="2", - path="", + read_from=("", ""), code=css, location=(0, 4), referenced_by=ReferencedBy( @@ -477,7 +477,7 @@ def test_multi_value_variable(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 5), referenced_by=ReferencedBy( @@ -487,7 +487,7 @@ def test_multi_value_variable(self): Token( name="number", value="4", - path="", + read_from=("", ""), code=css, location=(0, 6), referenced_by=ReferencedBy( @@ -497,7 +497,7 @@ def test_multi_value_variable(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(1, 8), referenced_by=None, @@ -505,7 +505,7 @@ def test_multi_value_variable(self): Token( name="number", value="2", - path="", + read_from=("", ""), code=css, location=(1, 9), referenced_by=None, @@ -513,7 +513,7 @@ def test_multi_value_variable(self): Token( name="variable_value_end", value="\n", - path="", + read_from=("", ""), code=css, location=(1, 10), referenced_by=None, @@ -521,7 +521,7 @@ def test_multi_value_variable(self): Token( name="selector_start_class", value=".thing", - path="", + read_from=("", ""), code=css, location=(2, 0), referenced_by=None, @@ -529,7 +529,7 @@ def test_multi_value_variable(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(2, 6), referenced_by=None, @@ -537,7 +537,7 @@ def test_multi_value_variable(self): Token( name="declaration_set_start", value="{", - path="", + read_from=("", ""), code=css, location=(2, 7), referenced_by=None, @@ -545,7 +545,7 @@ def test_multi_value_variable(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(2, 8), referenced_by=None, @@ -553,7 +553,7 @@ def test_multi_value_variable(self): Token( name="declaration_name", value="border:", - path="", + read_from=("", ""), code=css, location=(2, 9), referenced_by=None, @@ -561,7 +561,7 @@ def test_multi_value_variable(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(2, 16), referenced_by=None, @@ -569,7 +569,7 @@ def test_multi_value_variable(self): Token( name="number", value="6", - path="", + read_from=("", ""), code=css, location=(1, 4), referenced_by=ReferencedBy( @@ -579,7 +579,7 @@ def test_multi_value_variable(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(1, 5), referenced_by=ReferencedBy( @@ -589,7 +589,7 @@ def test_multi_value_variable(self): Token( name="number", value="2", - path="", + read_from=("", ""), code=css, location=(0, 4), referenced_by=ReferencedBy( @@ -599,7 +599,7 @@ def test_multi_value_variable(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 5), referenced_by=ReferencedBy( @@ -609,7 +609,7 @@ def test_multi_value_variable(self): Token( name="number", value="4", - path="", + read_from=("", ""), code=css, location=(0, 6), referenced_by=ReferencedBy( @@ -619,7 +619,7 @@ def test_multi_value_variable(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(1, 8), referenced_by=ReferencedBy( @@ -629,7 +629,7 @@ def test_multi_value_variable(self): Token( name="number", value="2", - path="", + read_from=("", ""), code=css, location=(1, 9), referenced_by=ReferencedBy( @@ -639,7 +639,7 @@ def test_multi_value_variable(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(2, 19), referenced_by=None, @@ -647,7 +647,7 @@ def test_multi_value_variable(self): Token( name="declaration_set_end", value="}", - path="", + read_from=("", ""), code=css, location=(2, 20), referenced_by=None, @@ -656,11 +656,11 @@ def test_multi_value_variable(self): def test_variable_used_inside_property_value(self): css = "$x: red\n.thing { border: on $x; }" - assert list(substitute_references(tokenize(css, ""))) == [ + assert list(substitute_references(tokenize(css, ("", "")))) == [ Token( name="variable_name", value="$x:", - path="", + read_from=("", ""), code=css, location=(0, 0), referenced_by=None, @@ -668,7 +668,7 @@ def test_variable_used_inside_property_value(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 3), referenced_by=None, @@ -676,7 +676,7 @@ def test_variable_used_inside_property_value(self): Token( name="token", value="red", - path="", + read_from=("", ""), code=css, location=(0, 4), referenced_by=None, @@ -684,7 +684,7 @@ def test_variable_used_inside_property_value(self): Token( name="variable_value_end", value="\n", - path="", + read_from=("", ""), code=css, location=(0, 7), referenced_by=None, @@ -692,7 +692,7 @@ def test_variable_used_inside_property_value(self): Token( name="selector_start_class", value=".thing", - path="", + read_from=("", ""), code=css, location=(1, 0), referenced_by=None, @@ -700,7 +700,7 @@ def test_variable_used_inside_property_value(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(1, 6), referenced_by=None, @@ -708,7 +708,7 @@ def test_variable_used_inside_property_value(self): Token( name="declaration_set_start", value="{", - path="", + read_from=("", ""), code=css, location=(1, 7), referenced_by=None, @@ -716,7 +716,7 @@ def test_variable_used_inside_property_value(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(1, 8), referenced_by=None, @@ -724,7 +724,7 @@ def test_variable_used_inside_property_value(self): Token( name="declaration_name", value="border:", - path="", + read_from=("", ""), code=css, location=(1, 9), referenced_by=None, @@ -732,7 +732,7 @@ def test_variable_used_inside_property_value(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(1, 16), referenced_by=None, @@ -740,7 +740,7 @@ def test_variable_used_inside_property_value(self): Token( name="token", value="on", - path="", + read_from=("", ""), code=css, location=(1, 17), referenced_by=None, @@ -748,7 +748,7 @@ def test_variable_used_inside_property_value(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(1, 19), referenced_by=None, @@ -756,7 +756,7 @@ def test_variable_used_inside_property_value(self): Token( name="token", value="red", - path="", + read_from=("", ""), code=css, location=(0, 4), referenced_by=ReferencedBy( @@ -766,7 +766,7 @@ def test_variable_used_inside_property_value(self): Token( name="declaration_end", value=";", - path="", + read_from=("", ""), code=css, location=(1, 22), referenced_by=None, @@ -774,7 +774,7 @@ def test_variable_used_inside_property_value(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(1, 23), referenced_by=None, @@ -782,7 +782,7 @@ def test_variable_used_inside_property_value(self): Token( name="declaration_set_end", value="}", - path="", + read_from=("", ""), code=css, location=(1, 24), referenced_by=None, @@ -791,11 +791,11 @@ def test_variable_used_inside_property_value(self): def test_variable_definition_eof(self): css = "$x: 1" - assert list(substitute_references(tokenize(css, ""))) == [ + assert list(substitute_references(tokenize(css, ("", "")))) == [ Token( name="variable_name", value="$x:", - path="", + read_from=("", ""), code=css, location=(0, 0), referenced_by=None, @@ -803,7 +803,7 @@ def test_variable_definition_eof(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 3), referenced_by=None, @@ -811,7 +811,7 @@ def test_variable_definition_eof(self): Token( name="number", value="1", - path="", + read_from=("", ""), code=css, location=(0, 4), referenced_by=None, @@ -820,11 +820,11 @@ def test_variable_definition_eof(self): def test_variable_reference_whitespace_trimming(self): css = "$x: 123;.thing{border: $x}" - assert list(substitute_references(tokenize(css, ""))) == [ + assert list(substitute_references(tokenize(css, ("", "")))) == [ Token( name="variable_name", value="$x:", - path="", + read_from=("", ""), code=css, location=(0, 0), referenced_by=None, @@ -832,7 +832,7 @@ def test_variable_reference_whitespace_trimming(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 3), referenced_by=None, @@ -840,7 +840,7 @@ def test_variable_reference_whitespace_trimming(self): Token( name="number", value="123", - path="", + read_from=("", ""), code=css, location=(0, 7), referenced_by=None, @@ -848,7 +848,7 @@ def test_variable_reference_whitespace_trimming(self): Token( name="variable_value_end", value=";", - path="", + read_from=("", ""), code=css, location=(0, 10), referenced_by=None, @@ -856,7 +856,7 @@ def test_variable_reference_whitespace_trimming(self): Token( name="selector_start_class", value=".thing", - path="", + read_from=("", ""), code=css, location=(0, 11), referenced_by=None, @@ -864,7 +864,7 @@ def test_variable_reference_whitespace_trimming(self): Token( name="declaration_set_start", value="{", - path="", + read_from=("", ""), code=css, location=(0, 17), referenced_by=None, @@ -872,7 +872,7 @@ def test_variable_reference_whitespace_trimming(self): Token( name="declaration_name", value="border:", - path="", + read_from=("", ""), code=css, location=(0, 18), referenced_by=None, @@ -880,7 +880,7 @@ def test_variable_reference_whitespace_trimming(self): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 25), referenced_by=None, @@ -888,7 +888,7 @@ def test_variable_reference_whitespace_trimming(self): Token( name="number", value="123", - path="", + read_from=("", ""), code=css, location=(0, 7), referenced_by=ReferencedBy( @@ -898,7 +898,7 @@ def test_variable_reference_whitespace_trimming(self): Token( name="declaration_set_end", value="}", - path="", + read_from=("", ""), code=css, location=(0, 28), referenced_by=None, diff --git a/tests/css/test_styles.py b/tests/css/test_styles.py index b31ad46548..7a4578200c 100644 --- a/tests/css/test_styles.py +++ b/tests/css/test_styles.py @@ -115,7 +115,7 @@ def test_get_opacity_default(): def test_styles_css_property(): css = "opacity: 50%; text-opacity: 20%; background: green; color: red; tint: dodgerblue 20%;" - styles = Styles().parse(css, path="") + styles = Styles().parse(css, read_from=("", "")) assert styles.css == ( "background: #008000;\n" "color: #FF0000;\n" diff --git a/tests/css/test_tokenize.py b/tests/css/test_tokenize.py index 01945c9dba..d4dfba888e 100644 --- a/tests/css/test_tokenize.py +++ b/tests/css/test_tokenize.py @@ -21,11 +21,11 @@ @pytest.mark.parametrize("name", VALID_VARIABLE_NAMES) def test_variable_declaration_valid_names(name): css = f"${name}: black on red;" - assert list(tokenize(css, "")) == [ + assert list(tokenize(css, ("", ""))) == [ Token( name="variable_name", value=f"${name}:", - path="", + read_from=("", ""), code=css, location=(0, 0), referenced_by=None, @@ -33,7 +33,7 @@ def test_variable_declaration_valid_names(name): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 14), referenced_by=None, @@ -41,7 +41,7 @@ def test_variable_declaration_valid_names(name): Token( name="token", value="black", - path="", + read_from=("", ""), code=css, location=(0, 15), referenced_by=None, @@ -49,7 +49,7 @@ def test_variable_declaration_valid_names(name): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 20), referenced_by=None, @@ -57,7 +57,7 @@ def test_variable_declaration_valid_names(name): Token( name="token", value="on", - path="", + read_from=("", ""), code=css, location=(0, 21), referenced_by=None, @@ -65,7 +65,7 @@ def test_variable_declaration_valid_names(name): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 23), referenced_by=None, @@ -73,7 +73,7 @@ def test_variable_declaration_valid_names(name): Token( name="token", value="red", - path="", + read_from=("", ""), code=css, location=(0, 24), referenced_by=None, @@ -81,7 +81,7 @@ def test_variable_declaration_valid_names(name): Token( name="variable_value_end", value=";", - path="", + read_from=("", ""), code=css, location=(0, 27), referenced_by=None, @@ -91,11 +91,11 @@ def test_variable_declaration_valid_names(name): def test_variable_declaration_multiple_values(): css = "$x: 2vw\t4% 6s red;" - assert list(tokenize(css, "")) == [ + assert list(tokenize(css, ("", ""))) == [ Token( name="variable_name", value="$x:", - path="", + read_from=("", ""), code=css, location=(0, 0), referenced_by=None, @@ -103,7 +103,7 @@ def test_variable_declaration_multiple_values(): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 3), referenced_by=None, @@ -111,7 +111,7 @@ def test_variable_declaration_multiple_values(): Token( name="scalar", value="2vw", - path="", + read_from=("", ""), code=css, location=(0, 4), referenced_by=None, @@ -119,7 +119,7 @@ def test_variable_declaration_multiple_values(): Token( name="whitespace", value="\t", - path="", + read_from=("", ""), code=css, location=(0, 7), referenced_by=None, @@ -127,7 +127,7 @@ def test_variable_declaration_multiple_values(): Token( name="scalar", value="4%", - path="", + read_from=("", ""), code=css, location=(0, 8), referenced_by=None, @@ -135,7 +135,7 @@ def test_variable_declaration_multiple_values(): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 10), referenced_by=None, @@ -143,7 +143,7 @@ def test_variable_declaration_multiple_values(): Token( name="duration", value="6s", - path="", + read_from=("", ""), code=css, location=(0, 11), referenced_by=None, @@ -151,7 +151,7 @@ def test_variable_declaration_multiple_values(): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 13), referenced_by=None, @@ -159,7 +159,7 @@ def test_variable_declaration_multiple_values(): Token( name="token", value="red", - path="", + read_from=("", ""), code=css, location=(0, 15), referenced_by=None, @@ -167,7 +167,7 @@ def test_variable_declaration_multiple_values(): Token( name="variable_value_end", value=";", - path="", + read_from=("", ""), code=css, location=(0, 18), referenced_by=None, @@ -183,112 +183,112 @@ def test_single_line_comment(): } # Nada""" # Check the css parses # list(parse(css, "")) - result = list(tokenize(css, "")) + result = list(tokenize(css, ("", ""))) print(result) expected = [ Token( name="whitespace", value="\n", - path="", + read_from=("", ""), code=css, location=(0, 9), ), Token( name="selector_start_id", value="#foo", - path="", + read_from=("", ""), code=css, location=(1, 0), ), Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(1, 4), ), Token( name="declaration_set_start", value="{", - path="", + read_from=("", ""), code=css, location=(1, 5), ), Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(1, 6), ), Token( name="whitespace", value="\n", - path="", + read_from=("", ""), code=css, location=(1, 16), ), Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(2, 0), ), Token( name="declaration_name", value="color:", - path="", + read_from=("", ""), code=css, location=(2, 4), ), Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(2, 10), ), Token( name="token", value="red", - path="", + read_from=("", ""), code=css, location=(2, 11), ), Token( name="declaration_end", value=";", - path="", + read_from=("", ""), code=css, location=(2, 14), ), Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(2, 15), ), Token( name="whitespace", value="\n", - path="", + read_from=("", ""), code=css, location=(2, 30), ), Token( name="declaration_set_end", value="}", - path="", + read_from=("", ""), code=css, location=(3, 0), ), Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(3, 1), ), @@ -298,11 +298,11 @@ def test_single_line_comment(): def test_variable_declaration_comment_ignored(): css = "$x: red; /* comment */" - assert list(tokenize(css, "")) == [ + assert list(tokenize(css, ("", ""))) == [ Token( name="variable_name", value="$x:", - path="", + read_from=("", ""), code=css, location=(0, 0), referenced_by=None, @@ -310,7 +310,7 @@ def test_variable_declaration_comment_ignored(): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 3), referenced_by=None, @@ -318,7 +318,7 @@ def test_variable_declaration_comment_ignored(): Token( name="token", value="red", - path="", + read_from=("", ""), code=css, location=(0, 4), referenced_by=None, @@ -326,7 +326,7 @@ def test_variable_declaration_comment_ignored(): Token( name="variable_value_end", value=";", - path="", + read_from=("", ""), code=css, location=(0, 7), referenced_by=None, @@ -334,7 +334,7 @@ def test_variable_declaration_comment_ignored(): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 8), referenced_by=None, @@ -344,11 +344,11 @@ def test_variable_declaration_comment_ignored(): def test_variable_declaration_comment_interspersed_ignored(): css = "$x: re/* comment */d;" - assert list(tokenize(css, "")) == [ + assert list(tokenize(css, ("", ""))) == [ Token( name="variable_name", value="$x:", - path="", + read_from=("", ""), code=css, location=(0, 0), referenced_by=None, @@ -356,7 +356,7 @@ def test_variable_declaration_comment_interspersed_ignored(): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 3), referenced_by=None, @@ -364,7 +364,7 @@ def test_variable_declaration_comment_interspersed_ignored(): Token( name="token", value="re", - path="", + read_from=("", ""), code=css, location=(0, 4), referenced_by=None, @@ -372,7 +372,7 @@ def test_variable_declaration_comment_interspersed_ignored(): Token( name="token", value="d", - path="", + read_from=("", ""), code=css, location=(0, 19), referenced_by=None, @@ -380,7 +380,7 @@ def test_variable_declaration_comment_interspersed_ignored(): Token( name="variable_value_end", value=";", - path="", + read_from=("", ""), code=css, location=(0, 20), referenced_by=None, @@ -390,11 +390,11 @@ def test_variable_declaration_comment_interspersed_ignored(): def test_variable_declaration_no_semicolon(): css = "$x: 1\n$y: 2" - assert list(tokenize(css, "")) == [ + assert list(tokenize(css, ("", ""))) == [ Token( name="variable_name", value="$x:", - path="", + read_from=("", ""), code=css, location=(0, 0), referenced_by=None, @@ -402,7 +402,7 @@ def test_variable_declaration_no_semicolon(): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 3), referenced_by=None, @@ -410,7 +410,7 @@ def test_variable_declaration_no_semicolon(): Token( name="number", value="1", - path="", + read_from=("", ""), code=css, location=(0, 4), referenced_by=None, @@ -418,7 +418,7 @@ def test_variable_declaration_no_semicolon(): Token( name="variable_value_end", value="\n", - path="", + read_from=("", ""), code=css, location=(0, 5), referenced_by=None, @@ -426,7 +426,7 @@ def test_variable_declaration_no_semicolon(): Token( name="variable_name", value="$y:", - path="", + read_from=("", ""), code=css, location=(1, 0), referenced_by=None, @@ -434,7 +434,7 @@ def test_variable_declaration_no_semicolon(): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(1, 3), referenced_by=None, @@ -442,7 +442,7 @@ def test_variable_declaration_no_semicolon(): Token( name="number", value="2", - path="", + read_from=("", ""), code=css, location=(1, 4), referenced_by=None, @@ -453,17 +453,17 @@ def test_variable_declaration_no_semicolon(): def test_variable_declaration_invalid_value(): css = "$x:(@$12x)" with pytest.raises(TokenError): - list(tokenize(css, "")) + list(tokenize(css, ("", ""))) def test_variables_declarations_amongst_rulesets(): css = "$x:1; .thing{text:red;} $y:2;" - tokens = list(tokenize(css, "")) + tokens = list(tokenize(css, ("", ""))) assert tokens == [ Token( name="variable_name", value="$x:", - path="", + read_from=("", ""), code=css, location=(0, 0), referenced_by=None, @@ -471,7 +471,7 @@ def test_variables_declarations_amongst_rulesets(): Token( name="number", value="1", - path="", + read_from=("", ""), code=css, location=(0, 3), referenced_by=None, @@ -479,7 +479,7 @@ def test_variables_declarations_amongst_rulesets(): Token( name="variable_value_end", value=";", - path="", + read_from=("", ""), code=css, location=(0, 4), referenced_by=None, @@ -487,7 +487,7 @@ def test_variables_declarations_amongst_rulesets(): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 5), referenced_by=None, @@ -495,7 +495,7 @@ def test_variables_declarations_amongst_rulesets(): Token( name="selector_start_class", value=".thing", - path="", + read_from=("", ""), code=css, location=(0, 6), referenced_by=None, @@ -503,7 +503,7 @@ def test_variables_declarations_amongst_rulesets(): Token( name="declaration_set_start", value="{", - path="", + read_from=("", ""), code=css, location=(0, 12), referenced_by=None, @@ -511,7 +511,7 @@ def test_variables_declarations_amongst_rulesets(): Token( name="declaration_name", value="text:", - path="", + read_from=("", ""), code=css, location=(0, 13), referenced_by=None, @@ -519,7 +519,7 @@ def test_variables_declarations_amongst_rulesets(): Token( name="token", value="red", - path="", + read_from=("", ""), code=css, location=(0, 18), referenced_by=None, @@ -527,7 +527,7 @@ def test_variables_declarations_amongst_rulesets(): Token( name="declaration_end", value=";", - path="", + read_from=("", ""), code=css, location=(0, 21), referenced_by=None, @@ -535,7 +535,7 @@ def test_variables_declarations_amongst_rulesets(): Token( name="declaration_set_end", value="}", - path="", + read_from=("", ""), code=css, location=(0, 22), referenced_by=None, @@ -543,7 +543,7 @@ def test_variables_declarations_amongst_rulesets(): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 23), referenced_by=None, @@ -551,7 +551,7 @@ def test_variables_declarations_amongst_rulesets(): Token( name="variable_name", value="$y:", - path="", + read_from=("", ""), code=css, location=(0, 24), referenced_by=None, @@ -559,7 +559,7 @@ def test_variables_declarations_amongst_rulesets(): Token( name="number", value="2", - path="", + read_from=("", ""), code=css, location=(0, 27), referenced_by=None, @@ -567,7 +567,7 @@ def test_variables_declarations_amongst_rulesets(): Token( name="variable_value_end", value=";", - path="", + read_from=("", ""), code=css, location=(0, 28), referenced_by=None, @@ -577,11 +577,11 @@ def test_variables_declarations_amongst_rulesets(): def test_variables_reference_in_rule_declaration_value(): css = ".warn{text: $warning;}" - assert list(tokenize(css, "")) == [ + assert list(tokenize(css, ("", ""))) == [ Token( name="selector_start_class", value=".warn", - path="", + read_from=("", ""), code=css, location=(0, 0), referenced_by=None, @@ -589,7 +589,7 @@ def test_variables_reference_in_rule_declaration_value(): Token( name="declaration_set_start", value="{", - path="", + read_from=("", ""), code=css, location=(0, 5), referenced_by=None, @@ -597,7 +597,7 @@ def test_variables_reference_in_rule_declaration_value(): Token( name="declaration_name", value="text:", - path="", + read_from=("", ""), code=css, location=(0, 6), referenced_by=None, @@ -605,7 +605,7 @@ def test_variables_reference_in_rule_declaration_value(): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 11), referenced_by=None, @@ -613,7 +613,7 @@ def test_variables_reference_in_rule_declaration_value(): Token( name="variable_ref", value="$warning", - path="", + read_from=("", ""), code=css, location=(0, 12), referenced_by=None, @@ -621,7 +621,7 @@ def test_variables_reference_in_rule_declaration_value(): Token( name="declaration_end", value=";", - path="", + read_from=("", ""), code=css, location=(0, 20), referenced_by=None, @@ -629,7 +629,7 @@ def test_variables_reference_in_rule_declaration_value(): Token( name="declaration_set_end", value="}", - path="", + read_from=("", ""), code=css, location=(0, 21), referenced_by=None, @@ -639,11 +639,11 @@ def test_variables_reference_in_rule_declaration_value(): def test_variables_reference_in_rule_declaration_value_multiple(): css = ".card{padding: $pad-y $pad-x;}" - assert list(tokenize(css, "")) == [ + assert list(tokenize(css, ("", ""))) == [ Token( name="selector_start_class", value=".card", - path="", + read_from=("", ""), code=css, location=(0, 0), referenced_by=None, @@ -651,7 +651,7 @@ def test_variables_reference_in_rule_declaration_value_multiple(): Token( name="declaration_set_start", value="{", - path="", + read_from=("", ""), code=css, location=(0, 5), referenced_by=None, @@ -659,7 +659,7 @@ def test_variables_reference_in_rule_declaration_value_multiple(): Token( name="declaration_name", value="padding:", - path="", + read_from=("", ""), code=css, location=(0, 6), referenced_by=None, @@ -667,7 +667,7 @@ def test_variables_reference_in_rule_declaration_value_multiple(): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 14), referenced_by=None, @@ -675,7 +675,7 @@ def test_variables_reference_in_rule_declaration_value_multiple(): Token( name="variable_ref", value="$pad-y", - path="", + read_from=("", ""), code=css, location=(0, 15), referenced_by=None, @@ -683,7 +683,7 @@ def test_variables_reference_in_rule_declaration_value_multiple(): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 21), referenced_by=None, @@ -691,7 +691,7 @@ def test_variables_reference_in_rule_declaration_value_multiple(): Token( name="variable_ref", value="$pad-x", - path="", + read_from=("", ""), code=css, location=(0, 22), referenced_by=None, @@ -699,7 +699,7 @@ def test_variables_reference_in_rule_declaration_value_multiple(): Token( name="declaration_end", value=";", - path="", + read_from=("", ""), code=css, location=(0, 28), referenced_by=None, @@ -707,7 +707,7 @@ def test_variables_reference_in_rule_declaration_value_multiple(): Token( name="declaration_set_end", value="}", - path="", + read_from=("", ""), code=css, location=(0, 29), referenced_by=None, @@ -717,11 +717,11 @@ def test_variables_reference_in_rule_declaration_value_multiple(): def test_variables_reference_in_variable_declaration(): css = "$x: $y;" - assert list(tokenize(css, "")) == [ + assert list(tokenize(css, ("", ""))) == [ Token( name="variable_name", value="$x:", - path="", + read_from=("", ""), code=css, location=(0, 0), referenced_by=None, @@ -729,7 +729,7 @@ def test_variables_reference_in_variable_declaration(): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 3), referenced_by=None, @@ -737,7 +737,7 @@ def test_variables_reference_in_variable_declaration(): Token( name="variable_ref", value="$y", - path="", + read_from=("", ""), code=css, location=(0, 4), referenced_by=None, @@ -745,7 +745,7 @@ def test_variables_reference_in_variable_declaration(): Token( name="variable_value_end", value=";", - path="", + read_from=("", ""), code=css, location=(0, 6), referenced_by=None, @@ -755,11 +755,11 @@ def test_variables_reference_in_variable_declaration(): def test_variable_references_in_variable_declaration_multiple(): css = "$x: $y $z\n" - assert list(tokenize(css, "")) == [ + assert list(tokenize(css, ("", ""))) == [ Token( name="variable_name", value="$x:", - path="", + read_from=("", ""), code=css, location=(0, 0), referenced_by=None, @@ -767,7 +767,7 @@ def test_variable_references_in_variable_declaration_multiple(): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 3), referenced_by=None, @@ -775,7 +775,7 @@ def test_variable_references_in_variable_declaration_multiple(): Token( name="variable_ref", value="$y", - path="", + read_from=("", ""), code=css, location=(0, 4), referenced_by=None, @@ -783,7 +783,7 @@ def test_variable_references_in_variable_declaration_multiple(): Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=css, location=(0, 6), referenced_by=None, @@ -791,7 +791,7 @@ def test_variable_references_in_variable_declaration_multiple(): Token( name="variable_ref", value="$z", - path="", + read_from=("", ""), code=css, location=(0, 8), referenced_by=None, @@ -799,7 +799,7 @@ def test_variable_references_in_variable_declaration_multiple(): Token( name="variable_value_end", value="\n", - path="", + read_from=("", ""), code=css, location=(0, 10), referenced_by=None, @@ -809,92 +809,92 @@ def test_variable_references_in_variable_declaration_multiple(): def test_allow_new_lines(): css = ".foo{margin: 1\n1 0 0}" - tokens = list(tokenize(css, "")) + tokens = list(tokenize(css, ("", ""))) print(repr(tokens)) expected = [ Token( name="selector_start_class", value=".foo", - path="", + read_from=("", ""), code=".foo{margin: 1\n1 0 0}", location=(0, 0), ), Token( name="declaration_set_start", value="{", - path="", + read_from=("", ""), code=".foo{margin: 1\n1 0 0}", location=(0, 4), ), Token( name="declaration_name", value="margin:", - path="", + read_from=("", ""), code=".foo{margin: 1\n1 0 0}", location=(0, 5), ), Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=".foo{margin: 1\n1 0 0}", location=(0, 12), ), Token( name="number", value="1", - path="", + read_from=("", ""), code=".foo{margin: 1\n1 0 0}", location=(0, 13), ), Token( name="whitespace", value="\n", - path="", + read_from=("", ""), code=".foo{margin: 1\n1 0 0}", location=(0, 14), ), Token( name="number", value="1", - path="", + read_from=("", ""), code=".foo{margin: 1\n1 0 0}", location=(1, 0), ), Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=".foo{margin: 1\n1 0 0}", location=(1, 1), ), Token( name="number", value="0", - path="", + read_from=("", ""), code=".foo{margin: 1\n1 0 0}", location=(1, 2), ), Token( name="whitespace", value=" ", - path="", + read_from=("", ""), code=".foo{margin: 1\n1 0 0}", location=(1, 3), ), Token( name="number", value="0", - path="", + read_from=("", ""), code=".foo{margin: 1\n1 0 0}", location=(1, 4), ), Token( name="declaration_set_end", value="}", - path="", + read_from=("", ""), code=".foo{margin: 1\n1 0 0}", location=(1, 5), ), ] - assert list(tokenize(css, "")) == expected + assert list(tokenize(css, ("", ""))) == expected From 0f38ab7202ee6e3ccb89375b77216e4b0eb4d03c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Tue, 24 Oct 2023 17:52:08 +0100 Subject: [PATCH 05/18] Link to correct file location in CSS errors. --- src/textual/css/stylesheet.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index 2af8857bde..abfa1df2a0 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -99,12 +99,16 @@ def __rich_console__( widget_text = Text( f" in {widget_var}:{line_no}:{col_no}", style="bold red" ) + link_url = f"file://{link_path}" if link_path else None else: path_string = f"{link_path or filename}:{line_no}:{col_no}" widget_text = Text() + link_url = ( + f"file://{link_path}#{line_no}:{col_no}" if link_path else None + ) link_style = Style( - link=f"file://{link_path}" if link_path else None, + link=link_url, color="red", bold=True, italic=True, From 8b16776afc976862e8a5533dd6b18395ea6281a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Wed, 25 Oct 2023 12:00:31 +0100 Subject: [PATCH 06/18] Drop links in CSS error reporting. Instead of creating the link explicitly, we let terminal emulators auto-link to the file. This came after a discussion about how/whether we should try to support linking to specific file lines/columns for TCSS files and after some research to see how that would be possible. We decided to drop this feature when we couldn't find information in the standards for 'file://' regarding how to specify line/column numbers and after we found [this iTerm issue](https://gitlab.com/gnachman/iterm2/-/issues/9376) where the creator/maintainer of iTerm says that there is no standard API for opening a file to a particular line number. --- CHANGELOG.md | 2 ++ src/textual/css/stylesheet.py | 27 +++------------------------ 2 files changed, 5 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6467ab5b43..816d6c5271 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - DataTable now has a max-height of 100vh rather than 100%, which doesn't work with auto - Breaking change: empty rules now result in an error https://github.com/Textualize/textual/pull/3566 - Improved startup time by caching CSS parsing https://github.com/Textualize/textual/pull/3575 +- CSS error reporting will no longer provide links to the files in question https://github.com/Textualize/textual/pull/3582 +- inline CSS error reporting will report widget/class variable where the CSS was read from https://github.com/Textualize/textual/pull/3582 ### Added diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index abfa1df2a0..057d3c606f 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -82,13 +82,6 @@ def __rich_console__( line_idx, col_idx = token.location line_no, col_no = line_idx + 1, col_idx + 1 - if widget_var: - path_string = ( - f"{link_path or filename} in {widget_var}:{line_no}:{col_no}" - ) - else: - path_string = f"{link_path or filename}:{line_no}:{col_no}" - # If we have a widget/variable from where the CSS was read, then line/column # numbers are relative to the inline CSS and we'll display them next to the # widget/variable. @@ -96,27 +89,13 @@ def __rich_console__( # next to the file path. if widget_var: path_string = link_path or filename - widget_text = Text( - f" in {widget_var}:{line_no}:{col_no}", style="bold red" - ) - link_url = f"file://{link_path}" if link_path else None + widget_string = f" in {widget_var}:{line_no}:{col_no}" else: path_string = f"{link_path or filename}:{line_no}:{col_no}" - widget_text = Text() - link_url = ( - f"file://{link_path}#{line_no}:{col_no}" if link_path else None - ) - - link_style = Style( - link=link_url, - color="red", - bold=True, - italic=True, - ) - path_text = Text(path_string, style=link_style) + widget_string = "" title = Text.assemble( - Text("Error at ", style="bold red"), path_text, widget_text + "Error at ", path_string, widget_string, style="bold red" ) yield "" yield Panel( From d84498232a6364584ea4dd62cb2a587013e7768f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Wed, 25 Oct 2023 13:21:56 +0100 Subject: [PATCH 07/18] Minor docstring/type hints cleanup. --- src/textual/css/stylesheet.py | 6 ++++-- src/textual/dom.py | 7 ++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index 057d3c606f..1186f735d9 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -290,6 +290,9 @@ def read_all(self, paths: Sequence[PurePath]) -> None: def has_source(self, read_from: CSSLocation) -> bool: """Check if the stylesheet has this CSS source already. + Args: + read_from: The location source of the CSS. + Returns: Whether the stylesheet is aware of this CSS source or not. """ @@ -307,8 +310,7 @@ def add_source( Args: css: String with CSS source. - location: The original location of the CSS as a pair of file path and class - variable (for the case where the CSS comes from a Python source file). + read_from: The original source location of the CSS. path: The path of the source if a file, or some other identifier. is_default_css: True if the CSS is defined in the Widget, False if the CSS is defined in a user stylesheet. diff --git a/src/textual/dom.py b/src/textual/dom.py index c380c6f4aa..51a6578e48 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -50,6 +50,7 @@ from rich.console import RenderableType from .app import App from .css.query import DOMQuery, QueryType + from .css.types import CSSLocation from .message import Message from .screen import Screen from .widget import Widget @@ -416,7 +417,7 @@ def __rich_repr__(self) -> rich.repr.Result: if hasattr(self, "_classes") and self._classes: yield "classes", " ".join(self._classes) - def _get_default_css(self) -> list[tuple[tuple[str, str], str, int, str]]: + def _get_default_css(self) -> list[tuple[CSSLocation, str, int, str]]: """Gets the CSS for this class and inherited from bases. Default CSS is inherited from base classes, unless `inherit_css` is set to @@ -427,9 +428,9 @@ def _get_default_css(self) -> list[tuple[tuple[str, str], str, int, str]]: class and inherited from base classes. """ - css_stack: list[tuple[tuple[str, str], str, int, str]] = [] + css_stack: list[tuple[CSSLocation, str, int, str]] = [] - def get_location(base: Type[DOMNode]) -> tuple[str, str]: + def get_location(base: Type[DOMNode]) -> CSSLocation: """Get the original location of this DEFAULT_CSS. Args: From 6c6eecfd10d570f1ddc2d8581bd641615c502a21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Wed, 25 Oct 2023 14:38:22 +0100 Subject: [PATCH 08/18] Address review feedback. --- src/textual/app.py | 7 ++++--- src/textual/css/stylesheet.py | 23 +++++++++++------------ 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index b004db1b89..0027204c41 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1753,7 +1753,7 @@ def _load_screen_css(self, screen: Screen): update = False for path in screen.css_path: - if not self.stylesheet.has_source((str(path), "")): + if not self.stylesheet.has_source(str(path), ""): self.stylesheet.read(path) update = True if screen.CSS: @@ -1761,8 +1761,9 @@ def _load_screen_css(self, screen: Screen): screen_path = inspect.getfile(screen.__class__) except (TypeError, OSError): screen_path = "" - read_from = (screen_path, f"{screen.__class__.__name__}.CSS") - if not self.stylesheet.has_source(read_from): + screen_class_var = f"{screen.__class__.__name__}.CSS" + read_from = (screen_path, screen_class_var) + if not self.stylesheet.has_source(screen_path, screen_class_var): self.stylesheet.add_source( screen.CSS, read_from=read_from, diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index 1186f735d9..3acbb56527 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -12,7 +12,6 @@ from rich.markup import render from rich.padding import Padding from rich.panel import Panel -from rich.style import Style from rich.syntax import Syntax from rich.text import Text @@ -68,20 +67,19 @@ def __rich_console__( for token, message in errors: error_count += 1 - if token.read_from: - display_path, widget_var = token.read_from - link_path = str(Path(display_path).absolute()) - filename = Path(link_path).name - else: - link_path = display_path = widget_var = "" - filename = "" - if token.referenced_by: line_idx, col_idx = token.referenced_by.location else: line_idx, col_idx = token.location line_no, col_no = line_idx + 1, col_idx + 1 + display_path, widget_var = token.read_from + if display_path: + link_path = str(Path(display_path).absolute()) + filename = Path(link_path).name + else: + link_path = "" + filename = "" # If we have a widget/variable from where the CSS was read, then line/column # numbers are relative to the inline CSS and we'll display them next to the # widget/variable. @@ -287,16 +285,17 @@ def read_all(self, paths: Sequence[PurePath]) -> None: for path in paths: self.read(path) - def has_source(self, read_from: CSSLocation) -> bool: + def has_source(self, path: str, class_var: str = "") -> bool: """Check if the stylesheet has this CSS source already. Args: - read_from: The location source of the CSS. + path: The file path of the source in question. + class_var: The widget class variable we might be reading the CSS from. Returns: Whether the stylesheet is aware of this CSS source or not. """ - return read_from in self.source + return (path, class_var) in self.source def add_source( self, From ecdf65385d07fe1d3e6abe26790618364d5e174f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Thu, 26 Oct 2023 11:28:26 +0100 Subject: [PATCH 09/18] Add more pre-commit hooks. --- .pre-commit-config.yaml | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b68fd51211..3bdb6ef564 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,10 +4,19 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.3.0 hooks: - - id: trailing-whitespace - - id: end-of-file-fixer + - id: check-ast + - id: check-builtin-literals + - id: check-case-conflict + - id: check-merge-conflict + - id: check-json + - id: check-toml - id: check-yaml args: [ '--unsafe' ] + - id: check-shebang-scripts-are-executable + - id: check-vcs-permalinks + - id: end-of-file-fixer + - id: mixed-line-ending + - id: trailing-whitespace - repo: https://github.com/pycqa/isort rev: 5.12.0 hooks: @@ -19,4 +28,9 @@ repos: rev: 23.1.0 hooks: - id: black + - repo: https://github.com/hadialqattan/pycln + rev: v2.3.0 + hooks: + - id: pycln + args: [--all] exclude: ^tests/snapshot_tests From ec44d17ccc5ac979c529dfd0a3ac82ade4417950 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Thu, 26 Oct 2023 11:55:22 +0100 Subject: [PATCH 10/18] Remove trailing whitespace hook. See https://github.com/Textualize/textual/pull/3595#discussion_r1372961156. --- .pre-commit-config.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3bdb6ef564..cf0a3df4fb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,7 +16,6 @@ repos: - id: check-vcs-permalinks - id: end-of-file-fixer - id: mixed-line-ending - - id: trailing-whitespace - repo: https://github.com/pycqa/isort rev: 5.12.0 hooks: From 6f00943c1474eae4d534e6b3e3eb27e4a649b7e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Thu, 26 Oct 2023 12:15:55 +0100 Subject: [PATCH 11/18] Fix imports. --- .../nonblocking01.py | 1 - docs/examples/events/on_decorator01.py | 1 - docs/examples/events/prevent.py | 1 - docs/examples/guide/reactivity/validate01.py | 2 +- docs/examples/styles/padding_all.py | 2 +- docs/examples/widgets/content_switcher.py | 2 -- src/textual/_parser.py | 2 -- src/textual/_types.py | 8 +------- src/textual/css/_style_properties.py | 2 +- src/textual/document/_document.py | 3 ++- src/textual/drivers/_input_reader_linux.py | 2 -- src/textual/drivers/linux_driver.py | 2 +- src/textual/message_pump.py | 1 - src/textual/widgets/_tabs.py | 1 - src/textual/widgets/_text_area.py | 3 +-- tests/css/test_programmatic_style_changes.py | 1 - tests/css/test_stylesheet.py | 1 - tests/input/test_input_validation.py | 1 - tests/test_reactive.py | 2 -- tests/test_screens.py | 4 ++-- tests/test_style_inheritance.py | 2 -- 21 files changed, 10 insertions(+), 34 deletions(-) diff --git a/docs/blog/snippets/2022-12-07-responsive-app-background-task/nonblocking01.py b/docs/blog/snippets/2022-12-07-responsive-app-background-task/nonblocking01.py index 20f2daba87..21e1760aaf 100644 --- a/docs/blog/snippets/2022-12-07-responsive-app-background-task/nonblocking01.py +++ b/docs/blog/snippets/2022-12-07-responsive-app-background-task/nonblocking01.py @@ -1,5 +1,4 @@ import asyncio -import time from random import randint from textual.app import App, ComposeResult diff --git a/docs/examples/events/on_decorator01.py b/docs/examples/events/on_decorator01.py index ac8e2ccd28..6612d6ad6c 100644 --- a/docs/examples/events/on_decorator01.py +++ b/docs/examples/events/on_decorator01.py @@ -1,4 +1,3 @@ -from textual import on from textual.app import App, ComposeResult from textual.widgets import Button diff --git a/docs/examples/events/prevent.py b/docs/examples/events/prevent.py index 39fe437c2d..61e48780d5 100644 --- a/docs/examples/events/prevent.py +++ b/docs/examples/events/prevent.py @@ -1,5 +1,4 @@ from textual.app import App, ComposeResult -from textual.containers import Horizontal from textual.widgets import Button, Input diff --git a/docs/examples/guide/reactivity/validate01.py b/docs/examples/guide/reactivity/validate01.py index 65d8113c07..e6ac1a75f2 100644 --- a/docs/examples/guide/reactivity/validate01.py +++ b/docs/examples/guide/reactivity/validate01.py @@ -30,7 +30,7 @@ def on_button_pressed(self, event: Button.Pressed) -> None: self.count += 1 else: self.count -= 1 - self.query_one(RichLog).write(f"{self.count=}") + self.query_one(RichLog).write(f"count = {self.count}") if __name__ == "__main__": diff --git a/docs/examples/styles/padding_all.py b/docs/examples/styles/padding_all.py index c857c26c1a..01f1ac6a76 100644 --- a/docs/examples/styles/padding_all.py +++ b/docs/examples/styles/padding_all.py @@ -1,5 +1,5 @@ from textual.app import App -from textual.containers import Container, Grid +from textual.containers import Grid from textual.widgets import Placeholder diff --git a/docs/examples/widgets/content_switcher.py b/docs/examples/widgets/content_switcher.py index 82cb43aace..8e235fe564 100644 --- a/docs/examples/widgets/content_switcher.py +++ b/docs/examples/widgets/content_switcher.py @@ -1,5 +1,3 @@ -from rich.align import VerticalCenter - from textual.app import App, ComposeResult from textual.containers import Horizontal, VerticalScroll from textual.widgets import Button, ContentSwitcher, DataTable, Markdown diff --git a/src/textual/_parser.py b/src/textual/_parser.py index a1a187da46..812e063882 100644 --- a/src/textual/_parser.py +++ b/src/textual/_parser.py @@ -165,8 +165,6 @@ def parse( test_parser = TestParser() - import time - for n in range(0, len(data), 5): for token in test_parser.feed(data[n : n + 5]): print(token) diff --git a/src/textual/_types.py b/src/textual/_types.py index 669950c5a2..b1ad7972f3 100644 --- a/src/textual/_types.py +++ b/src/textual/_types.py @@ -1,12 +1,6 @@ from typing import TYPE_CHECKING, Any, Awaitable, Callable, List, Union -from typing_extensions import ( - Literal, - Protocol, - SupportsIndex, - get_args, - runtime_checkable, -) +from typing_extensions import Protocol if TYPE_CHECKING: from rich.segment import Segment diff --git a/src/textual/css/_style_properties.py b/src/textual/css/_style_properties.py index 9667e97c65..7ad26dbbf8 100644 --- a/src/textual/css/_style_properties.py +++ b/src/textual/css/_style_properties.py @@ -47,7 +47,7 @@ if TYPE_CHECKING: from .._layout import Layout - from .styles import Styles, StylesBase + from .styles import StylesBase from .types import AlignHorizontal, AlignVertical, DockEdge, EdgeType diff --git a/src/textual/document/_document.py b/src/textual/document/_document.py index 6fa2341911..783a829e98 100644 --- a/src/textual/document/_document.py +++ b/src/textual/document/_document.py @@ -5,12 +5,13 @@ from functools import lru_cache from typing import TYPE_CHECKING, NamedTuple, Tuple, overload +from typing_extensions import Literal, get_args + if TYPE_CHECKING: from tree_sitter import Node from tree_sitter.binding import Query from textual._cells import cell_len -from textual._types import Literal, get_args from textual.geometry import Size Newline = Literal["\r\n", "\n", "\r"] diff --git a/src/textual/drivers/_input_reader_linux.py b/src/textual/drivers/_input_reader_linux.py index 82c032e0b6..04604d820b 100644 --- a/src/textual/drivers/_input_reader_linux.py +++ b/src/textual/drivers/_input_reader_linux.py @@ -4,8 +4,6 @@ from threading import Event from typing import Iterator -from textual import log - class InputReader: """Read input from stdin.""" diff --git a/src/textual/drivers/linux_driver.py b/src/textual/drivers/linux_driver.py index c64f9dcdde..cfc0c5d09d 100644 --- a/src/textual/drivers/linux_driver.py +++ b/src/textual/drivers/linux_driver.py @@ -14,7 +14,7 @@ import rich.repr import rich.traceback -from .. import events, log +from .. import events from .._xterm_parser import XTermParser from ..driver import Driver from ..geometry import Size diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 673d75079d..33c80fa807 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -26,7 +26,6 @@ from ._context import prevent_message_types_stack from ._on import OnNoWidget from ._time import time -from ._types import CallbackType from .case import camel_to_snake from .css.match import match from .errors import DuplicateKeyHandlers diff --git a/src/textual/widgets/_tabs.py b/src/textual/widgets/_tabs.py index ab54de158c..e8b8b83786 100644 --- a/src/textual/widgets/_tabs.py +++ b/src/textual/widgets/_tabs.py @@ -1,7 +1,6 @@ from __future__ import annotations import asyncio -from asyncio import create_task from dataclasses import dataclass from typing import ClassVar diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index 0fbcba4c34..20b30ff7a5 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -9,6 +9,7 @@ from rich.style import Style from rich.text import Text +from typing_extensions import Literal, Protocol, runtime_checkable from textual._text_area_theme import TextAreaTheme from textual._tree_sitter import TREE_SITTER @@ -30,11 +31,9 @@ if TYPE_CHECKING: from tree_sitter import Language - from tree_sitter.binding import Query from textual import events, log from textual._cells import cell_len -from textual._types import Literal, Protocol, runtime_checkable from textual.binding import Binding from textual.events import Message, MouseEvent from textual.geometry import Offset, Region, Size, Spacing, clamp diff --git a/tests/css/test_programmatic_style_changes.py b/tests/css/test_programmatic_style_changes.py index f15ecdd55d..c81d88fa31 100644 --- a/tests/css/test_programmatic_style_changes.py +++ b/tests/css/test_programmatic_style_changes.py @@ -2,7 +2,6 @@ from textual.app import App from textual.containers import Grid -from textual.screen import Screen from textual.widgets import Label diff --git a/tests/css/test_stylesheet.py b/tests/css/test_stylesheet.py index abb1afe91a..3d0113811d 100644 --- a/tests/css/test_stylesheet.py +++ b/tests/css/test_stylesheet.py @@ -1,5 +1,4 @@ from contextlib import nullcontext as does_not_raise -from typing import Any import pytest diff --git a/tests/input/test_input_validation.py b/tests/input/test_input_validation.py index cfbdf32928..c260a4318f 100644 --- a/tests/input/test_input_validation.py +++ b/tests/input/test_input_validation.py @@ -2,7 +2,6 @@ from textual import on from textual.app import App, ComposeResult -from textual.events import Blur from textual.validation import Number, ValidationResult from textual.widgets import Input diff --git a/tests/test_reactive.py b/tests/test_reactive.py index 9ab1af192c..8ee7861a2a 100644 --- a/tests/test_reactive.py +++ b/tests/test_reactive.py @@ -298,8 +298,6 @@ class Secondary(Primary): class Tertiary(Secondary): baz = reactive("baz") - from rich import print - primary = Primary() secondary = Secondary() tertiary = Tertiary() diff --git a/tests/test_screens.py b/tests/test_screens.py index 2163a715ef..83fcde4932 100644 --- a/tests/test_screens.py +++ b/tests/test_screens.py @@ -6,10 +6,10 @@ from textual import work from textual.app import App, ComposeResult, ScreenStackError -from textual.events import MouseMove, MouseScrollDown, MouseScrollUp +from textual.events import MouseMove from textual.geometry import Offset from textual.screen import Screen -from textual.widgets import Button, DataTable, Input, Label +from textual.widgets import Button, Input, Label from textual.worker import NoActiveWorker skip_py310 = pytest.mark.skipif( diff --git a/tests/test_style_inheritance.py b/tests/test_style_inheritance.py index b6e264c9d0..72b631a94a 100644 --- a/tests/test_style_inheritance.py +++ b/tests/test_style_inheritance.py @@ -1,5 +1,3 @@ -from rich.style import Style - from textual.app import App, ComposeResult from textual.widgets import Button, Static From d9594f5adc6a5d35398989baa8b579a5fc3a8bb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Thu, 26 Oct 2023 12:20:19 +0100 Subject: [PATCH 12/18] Comment what the hooks do. --- .pre-commit-config.yaml | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cf0a3df4fb..536e8c63bd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,18 +4,18 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.3.0 hooks: - - id: check-ast - - id: check-builtin-literals - - id: check-case-conflict - - id: check-merge-conflict - - id: check-json - - id: check-toml - - id: check-yaml - args: [ '--unsafe' ] - - id: check-shebang-scripts-are-executable - - id: check-vcs-permalinks - - id: end-of-file-fixer - - id: mixed-line-ending + - id: check-ast # simply checks whether the files parse as valid python + - id: check-builtin-literals # requires literal syntax when initializing empty or zero python builtin types + - id: check-case-conflict # checks for files that would conflict in case-insensitive filesystems + - id: check-merge-conflict # checks for files that contain merge conflict strings + - id: check-json # checks json files for parseable syntax + - id: check-toml # checks toml files for parseable syntax + - id: check-yaml # checks yaml files for parseable syntax + args: [ '--unsafe' ] # Instead of loading the files, simply parse them for syntax. + - id: check-shebang-scripts-are-executable # ensures that (non-binary) files with a shebang are executable + - id: check-vcs-permalinks # ensures that links to vcs websites are permalinks + - id: end-of-file-fixer # ensures that a file is either empty, or ends with one newline + - id: mixed-line-ending # replaces or checks mixed line ending - repo: https://github.com/pycqa/isort rev: 5.12.0 hooks: @@ -27,7 +27,7 @@ repos: rev: 23.1.0 hooks: - id: black - - repo: https://github.com/hadialqattan/pycln + - repo: https://github.com/hadialqattan/pycln # removes unused imports rev: v2.3.0 hooks: - id: pycln From b9392560073667ed1120c7f00ba791450db300d7 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 31 Oct 2023 08:47:33 +0000 Subject: [PATCH 13/18] Add a test that the command palette doesn't kill other workers See #3615. --- .../test_worker_interference.py | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 tests/command_palette/test_worker_interference.py diff --git a/tests/command_palette/test_worker_interference.py b/tests/command_palette/test_worker_interference.py new file mode 100644 index 0000000000..ffc7448568 --- /dev/null +++ b/tests/command_palette/test_worker_interference.py @@ -0,0 +1,37 @@ +"""Tests for https://github.com/Textualize/textual/issues/3615""" + +from asyncio import sleep + +from textual import work +from textual.app import App +from textual.command import Hit, Hits, Provider + + +class SimpleSource(Provider): + async def search(self, query: str) -> Hits: + def goes_nowhere_does_nothing() -> None: + pass + + for _ in range(100): + yield Hit(1, query, goes_nowhere_does_nothing, query) + + +class CommandPaletteApp(App[None]): + COMMANDS = {SimpleSource} + + def on_mount(self) -> None: + self.innocent_worker() + + @work + async def innocent_worker(self) -> None: + while True: + await sleep(1) + + +async def test_innocent_worker_is_untouched() -> None: + """Using the command palette should not halt other workers.""" + async with CommandPaletteApp().run_test() as pilot: + assert len(pilot.app.workers) > 0 + pilot.app.action_command_palette() + await pilot.press("a", "enter") + assert len(pilot.app.workers) > 0 From 7cbba6636fb3fa48e820b0640b1d4a86e1e2d8be Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 31 Oct 2023 08:54:25 +0000 Subject: [PATCH 14/18] Add a test for the command palette not leaving workers behind --- .../command_palette/test_worker_interference.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/tests/command_palette/test_worker_interference.py b/tests/command_palette/test_worker_interference.py index ffc7448568..b259d748e5 100644 --- a/tests/command_palette/test_worker_interference.py +++ b/tests/command_palette/test_worker_interference.py @@ -16,7 +16,20 @@ def goes_nowhere_does_nothing() -> None: yield Hit(1, query, goes_nowhere_does_nothing, query) -class CommandPaletteApp(App[None]): +class CommandPaletteNoWorkerApp(App[None]): + COMMANDS = {SimpleSource} + + +async def test_no_command_palette_worker_droppings() -> None: + """The command palette should not leave any workers behind..""" + async with CommandPaletteNoWorkerApp().run_test() as pilot: + assert len(pilot.app.workers) == 0 + pilot.app.action_command_palette() + await pilot.press("a", "enter") + assert len(pilot.app.workers) == 0 + + +class CommandPaletteWithWorkerApp(App[None]): COMMANDS = {SimpleSource} def on_mount(self) -> None: @@ -30,7 +43,7 @@ async def innocent_worker(self) -> None: async def test_innocent_worker_is_untouched() -> None: """Using the command palette should not halt other workers.""" - async with CommandPaletteApp().run_test() as pilot: + async with CommandPaletteWithWorkerApp().run_test() as pilot: assert len(pilot.app.workers) > 0 pilot.app.action_command_palette() await pilot.press("a", "enter") From 449d3a6b7cb00274df462b3a40740af914baadf4 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 31 Oct 2023 08:58:27 +0000 Subject: [PATCH 15/18] Ensure that the command palette only cancels its own workers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit See #3615. Co-authored-by: Rodrigo Girão Serrão --- src/textual/command.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/textual/command.py b/src/textual/command.py index b389096e7b..1922c296ea 100644 --- a/src/textual/command.py +++ b/src/textual/command.py @@ -515,7 +515,7 @@ def _on_click(self, event: Click) -> None: method of dismissing the palette. """ if self.get_widget_at(event.screen_x, event.screen_y)[0] is self: - self.workers.cancel_all() + self._cancel_gather_commands() self.dismiss() def on_mount(self, _: Mount) -> None: @@ -774,7 +774,10 @@ def _refresh_command_list( _NO_MATCHES: Final[str] = "--no-matches" """The ID to give the disabled option that shows there were no matches.""" - @work(exclusive=True) + _GATHER_COMMANDS_GROUP: Final[str] = "--textual-command-palette-gather-commands" + """The group name of the command gathering worker.""" + + @work(exclusive=True, group=_GATHER_COMMANDS_GROUP) async def _gather_commands(self, search_value: str) -> None: """Gather up all of the commands that match the search value. @@ -895,6 +898,10 @@ async def _gather_commands(self, search_value: str) -> None: if command_list.option_count == 0 and not worker.is_cancelled: self._start_no_matches_countdown() + def _cancel_gather_commands(self) -> None: + """Cancel any operation that is gather commands.""" + self.workers.cancel_group(self, self._GATHER_COMMANDS_GROUP) + @on(Input.Changed) def _input(self, event: Input.Changed) -> None: """React to input in the command palette. @@ -903,7 +910,7 @@ def _input(self, event: Input.Changed) -> None: event: The input event. """ event.stop() - self.workers.cancel_all() + self._cancel_gather_commands() self._stop_no_matches_countdown() search_value = event.value.strip() @@ -921,7 +928,7 @@ def _select_command(self, event: OptionList.OptionSelected) -> None: event: The option selection event. """ event.stop() - self.workers.cancel_all() + self._cancel_gather_commands() input = self.query_one(CommandInput) with self.prevent(Input.Changed): assert isinstance(event.option, Command) @@ -958,7 +965,7 @@ def _select_or_command( if self._selected_command is not None: # ...we should return it to the parent screen and let it # decide what to do with it (hopefully it'll run it). - self.workers.cancel_all() + self._cancel_gather_commands() self.dismiss(self._selected_command.command) @on(OptionList.OptionHighlighted) @@ -971,7 +978,7 @@ def _action_escape(self) -> None: if self._list_visible: self._list_visible = False else: - self.workers.cancel_all() + self._cancel_gather_commands() self.dismiss() def _action_command_list(self, action: str) -> None: From 9cacf8cd7b74ccf2d26ba274d4ee8fcff0c4317d Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 31 Oct 2023 09:04:56 +0000 Subject: [PATCH 16/18] Update the ChangeLog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf154b5ba8..586441183f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Fixed `OptionList` event leakage from `CommandPalette` to `App`. - Fixed crash in `LoadingIndicator` https://github.com/Textualize/textual/pull/3498 - Fixed crash when `Tabs` appeared as a descendant of `TabbedContent` in the DOM https://github.com/Textualize/textual/pull/3602 +- Fixed the command palette cancelling other workers https://github.com/Textualize/textual/issues/3615 ### Added From 4f95d30619b3e65386dd469f6e26d9be1b08f850 Mon Sep 17 00:00:00 2001 From: Josh Duncan <44387852+joshbduncan@users.noreply.github.com> Date: Tue, 31 Oct 2023 09:14:47 -0400 Subject: [PATCH 17/18] DataTable sort by function (or other callable) (#3090) * DataTable sort by function (or other callable) The `DataTable` widget now takes the `by` argument instead of `columns`, allowing the table to also be sorted using a custom function (or other callable). This is a breaking change since it requires all calls to the `sort` method to include an iterable of key(s) (or a singular function/callable). Covers #2261 using [suggested function signature](https://github.com/Textualize/textual/pull/2512#issuecomment-1580277771) from @darrenburns on PR #2512. * argument change and functionaloty update Changed back to orinal `columns` argument and added a new `key` argument which takes a function (or other callable). This allows the PR to NOT BE a breaking change. * better example for docs - Updated the example file for the docs to better show the functionality of the change (especially when using `columns` and `key` together). - Added one new tests to cover a similar situation to the example changes * removed unecessary code from example - the sort by clicked column function was bloat in my opinion * requested changes * simplify method and terminology * combine key_wrapper and default sort * Removing some tests from DataTable.sort as duplicates. Ensure there is test coverage of the case where a key, but no columns, is passed to DataTable.sort. * Remove unused import * Fix merge issues in CHANGELOG, update DataTable sort-by-key changelog PR link --------- Co-authored-by: Darren Burns Co-authored-by: Darren Burns --- CHANGELOG.md | 15 ++-- docs/examples/widgets/data_table_sort.py | 92 +++++++++++++++++++++++ docs/widgets/data_table.md | 21 ++++-- src/textual/widgets/_data_table.py | 26 +++++-- tests/test_data_table.py | 94 ++++++++++++++++++++++++ 5 files changed, 225 insertions(+), 23 deletions(-) create mode 100644 docs/examples/widgets/data_table_sort.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 586441183f..2f02bd89ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Add Document `get_index_from_location` / `get_location_from_index` https://github.com/Textualize/textual/pull/3410 - Add setter for `TextArea.text` https://github.com/Textualize/textual/discussions/3525 +- Added `key` argument to the `DataTable.sort()` method, allowing the table to be sorted using a custom function (or other callable) https://github.com/Textualize/textual/pull/3090 +- Added `initial` to all css rules, which restores default (i.e. value from DEFAULT_CSS) https://github.com/Textualize/textual/pull/3566 +- Added HorizontalPad to pad.py https://github.com/Textualize/textual/pull/3571 +- Added `AwaitComplete` class, to be used for optionally awaitable return values https://github.com/Textualize/textual/pull/3498 + ### Changed @@ -49,15 +54,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Improved startup time by caching CSS parsing https://github.com/Textualize/textual/pull/3575 - Workers are now created/run in a thread-safe way https://github.com/Textualize/textual/pull/3586 -### Added - -- Added `initial` to all css rules, which restores default (i.e. value from DEFAULT_CSS) https://github.com/Textualize/textual/pull/3566 -- Added HorizontalPad to pad.py https://github.com/Textualize/textual/pull/3571 - -### Added - -- Added `AwaitComplete` class, to be used for optionally awaitable return values https://github.com/Textualize/textual/pull/3498 - ## [0.40.0] - 2023-10-11 ### Added @@ -251,7 +247,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - DescendantBlur and DescendantFocus can now be used with @on decorator - ## [0.32.0] - 2023-08-03 ### Added diff --git a/docs/examples/widgets/data_table_sort.py b/docs/examples/widgets/data_table_sort.py new file mode 100644 index 0000000000..599a629394 --- /dev/null +++ b/docs/examples/widgets/data_table_sort.py @@ -0,0 +1,92 @@ +from rich.text import Text + +from textual.app import App, ComposeResult +from textual.widgets import DataTable, Footer + +ROWS = [ + ("lane", "swimmer", "country", "time 1", "time 2"), + (4, "Joseph Schooling", Text("Singapore", style="italic"), 50.39, 51.84), + (2, "Michael Phelps", Text("United States", style="italic"), 50.39, 51.84), + (5, "Chad le Clos", Text("South Africa", style="italic"), 51.14, 51.73), + (6, "László Cseh", Text("Hungary", style="italic"), 51.14, 51.58), + (3, "Li Zhuhao", Text("China", style="italic"), 51.26, 51.26), + (8, "Mehdy Metella", Text("France", style="italic"), 51.58, 52.15), + (7, "Tom Shields", Text("United States", style="italic"), 51.73, 51.12), + (1, "Aleksandr Sadovnikov", Text("Russia", style="italic"), 51.84, 50.85), + (10, "Darren Burns", Text("Scotland", style="italic"), 51.84, 51.55), +] + + +class TableApp(App): + BINDINGS = [ + ("a", "sort_by_average_time", "Sort By Average Time"), + ("n", "sort_by_last_name", "Sort By Last Name"), + ("c", "sort_by_country", "Sort By Country"), + ("d", "sort_by_columns", "Sort By Columns (Only)"), + ] + + current_sorts: set = set() + + def compose(self) -> ComposeResult: + yield DataTable() + yield Footer() + + def on_mount(self) -> None: + table = self.query_one(DataTable) + for col in ROWS[0]: + table.add_column(col, key=col) + table.add_rows(ROWS[1:]) + + def sort_reverse(self, sort_type: str): + """Determine if `sort_type` is ascending or descending.""" + reverse = sort_type in self.current_sorts + if reverse: + self.current_sorts.remove(sort_type) + else: + self.current_sorts.add(sort_type) + return reverse + + def action_sort_by_average_time(self) -> None: + """Sort DataTable by average of times (via a function) and + passing of column data through positional arguments.""" + + def sort_by_average_time_then_last_name(row_data): + name, *scores = row_data + return (sum(scores) / len(scores), name.split()[-1]) + + table = self.query_one(DataTable) + table.sort( + "swimmer", + "time 1", + "time 2", + key=sort_by_average_time_then_last_name, + reverse=self.sort_reverse("time"), + ) + + def action_sort_by_last_name(self) -> None: + """Sort DataTable by last name of swimmer (via a lambda).""" + table = self.query_one(DataTable) + table.sort( + "swimmer", + key=lambda swimmer: swimmer.split()[-1], + reverse=self.sort_reverse("swimmer"), + ) + + def action_sort_by_country(self) -> None: + """Sort DataTable by country which is a `Rich.Text` object.""" + table = self.query_one(DataTable) + table.sort( + "country", + key=lambda country: country.plain, + reverse=self.sort_reverse("country"), + ) + + def action_sort_by_columns(self) -> None: + """Sort DataTable without a key.""" + table = self.query_one(DataTable) + table.sort("swimmer", "lane", reverse=self.sort_reverse("columns")) + + +app = TableApp() +if __name__ == "__main__": + app.run() diff --git a/docs/widgets/data_table.md b/docs/widgets/data_table.md index a676c4dca1..ab1981c0f1 100644 --- a/docs/widgets/data_table.md +++ b/docs/widgets/data_table.md @@ -143,11 +143,22 @@ visible as you scroll through the data table. ### Sorting -The `DataTable` can be sorted using the [sort][textual.widgets.DataTable.sort] method. -In order to sort your data by a column, you must have supplied a `key` to the `add_column` method -when you added it. -You can then pass this key to the `sort` method to sort by that column. -Additionally, you can sort by multiple columns by passing multiple keys to `sort`. +The `DataTable` can be sorted using the [sort][textual.widgets.DataTable.sort] method. In order to sort your data by a column, you can provide the `key` you supplied to the `add_column` method or a `ColumnKey`. You can then pass one more column keys to the `sort` method to sort by one or more columns. + +Additionally, you can sort your `DataTable` with a custom function (or other callable) via the `key` argument. Similar to the `key` parameter of the built-in [sorted()](https://docs.python.org/3/library/functions.html#sorted) function, your function (or other callable) should take a single argument (row) and return a key to use for sorting purposes. + +Providing both `columns` and `key` will limit the row information sent to your `key` function (or other callable) to only the columns specified. + +=== "Output" + + ```{.textual path="docs/examples/widgets/data_table_sort.py"} + ``` + +=== "data_table_sort.py" + + ```python + --8<-- "docs/examples/widgets/data_table_sort.py" + ``` ### Labelled rows diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 4b482424f9..54c35a63a3 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from itertools import chain, zip_longest from operator import itemgetter -from typing import Any, ClassVar, Generic, Iterable, NamedTuple, TypeVar, cast +from typing import Any, Callable, ClassVar, Generic, Iterable, NamedTuple, TypeVar, cast import rich.repr from rich.console import RenderableType @@ -2348,30 +2348,40 @@ def _get_fixed_offset(self) -> Spacing: def sort( self, *columns: ColumnKey | str, + key: Callable[[Any], Any] | None = None, reverse: bool = False, ) -> Self: - """Sort the rows in the `DataTable` by one or more column keys. + """Sort the rows in the `DataTable` by one or more column keys or a + key function (or other callable). If both columns and a key function + are specified, only data from those columns will sent to the key function. Args: columns: One or more columns to sort by the values in. + key: A function (or other callable) that returns a key to + use for sorting purposes. reverse: If True, the sort order will be reversed. Returns: The `DataTable` instance. """ - def sort_by_column_keys( - row: tuple[RowKey, dict[ColumnKey | str, CellType]] - ) -> Any: + def key_wrapper(row: tuple[RowKey, dict[ColumnKey | str, CellType]]) -> Any: _, row_data = row - result = itemgetter(*columns)(row_data) + if columns: + result = itemgetter(*columns)(row_data) + else: + result = tuple(row_data.values()) + if key is not None: + return key(result) return result ordered_rows = sorted( - self._data.items(), key=sort_by_column_keys, reverse=reverse + self._data.items(), + key=key_wrapper, + reverse=reverse, ) self._row_locations = TwoWayDict( - {key: new_index for new_index, (key, _) in enumerate(ordered_rows)} + {row_key: new_index for new_index, (row_key, _) in enumerate(ordered_rows)} ) self._update_count += 1 self.refresh() diff --git a/tests/test_data_table.py b/tests/test_data_table.py index 15ded2563e..4f473f978f 100644 --- a/tests/test_data_table.py +++ b/tests/test_data_table.py @@ -1197,6 +1197,100 @@ async def test_unset_hover_highlight_when_no_table_cell_under_mouse(): assert not table._show_hover_cursor +async def test_sort_by_all_columns_no_key(): + """Test sorting a `DataTable` by all columns.""" + + app = DataTableApp() + async with app.run_test(): + table = app.query_one(DataTable) + a, b, c = table.add_columns("A", "B", "C") + table.add_row(1, 3, 8) + table.add_row(2, 9, 5) + table.add_row(1, 1, 9) + assert table.get_row_at(0) == [1, 3, 8] + assert table.get_row_at(1) == [2, 9, 5] + assert table.get_row_at(2) == [1, 1, 9] + + table.sort() + assert table.get_row_at(0) == [1, 1, 9] + assert table.get_row_at(1) == [1, 3, 8] + assert table.get_row_at(2) == [2, 9, 5] + + table.sort(reverse=True) + assert table.get_row_at(0) == [2, 9, 5] + assert table.get_row_at(1) == [1, 3, 8] + assert table.get_row_at(2) == [1, 1, 9] + + +async def test_sort_by_multiple_columns_no_key(): + """Test sorting a `DataTable` by multiple columns.""" + + app = DataTableApp() + async with app.run_test(): + table = app.query_one(DataTable) + a, b, c = table.add_columns("A", "B", "C") + table.add_row(1, 3, 8) + table.add_row(2, 9, 5) + table.add_row(1, 1, 9) + + table.sort(a, b, c) + assert table.get_row_at(0) == [1, 1, 9] + assert table.get_row_at(1) == [1, 3, 8] + assert table.get_row_at(2) == [2, 9, 5] + + table.sort(a, c, b) + assert table.get_row_at(0) == [1, 3, 8] + assert table.get_row_at(1) == [1, 1, 9] + assert table.get_row_at(2) == [2, 9, 5] + + table.sort(c, a, b, reverse=True) + assert table.get_row_at(0) == [1, 1, 9] + assert table.get_row_at(1) == [1, 3, 8] + assert table.get_row_at(2) == [2, 9, 5] + + table.sort(a, c) + assert table.get_row_at(0) == [1, 3, 8] + assert table.get_row_at(1) == [1, 1, 9] + assert table.get_row_at(2) == [2, 9, 5] + + +async def test_sort_by_function_sum(): + """Test sorting a `DataTable` using a custom sort function.""" + + def custom_sort(row_data): + return sum(row_data) + + row_data = ( + [1, 3, 8], # SUM=12 + [2, 9, 5], # SUM=16 + [1, 1, 9], # SUM=11 + ) + + app = DataTableApp() + async with app.run_test(): + table = app.query_one(DataTable) + a, b, c = table.add_columns("A", "B", "C") + for i, row in enumerate(row_data): + table.add_row(*row) + + # Sorting by all columns + table.sort(a, b, c, key=custom_sort) + sorted_row_data = sorted(row_data, key=sum) + for i, row in enumerate(sorted_row_data): + assert table.get_row_at(i) == row + + # Passing a sort function but no columns also sorts by all columns + table.sort(key=custom_sort) + sorted_row_data = sorted(row_data, key=sum) + for i, row in enumerate(sorted_row_data): + assert table.get_row_at(i) == row + + table.sort(a, b, c, key=custom_sort, reverse=True) + sorted_row_data = sorted(row_data, key=sum, reverse=True) + for i, row in enumerate(sorted_row_data): + assert table.get_row_at(i) == row + + @pytest.mark.parametrize( ["cell", "height"], [ From b3d60432f199be944dd43490be2da7db84da6c6e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 31 Oct 2023 13:24:03 +0000 Subject: [PATCH 18/18] Version bump (#3621) --- CHANGELOG.md | 3 ++- pyproject.toml | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f02bd89ba..b3ea067bc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). -## Unreleased +## [0.41.0] - 2023-10-31 ### Fixed @@ -1394,6 +1394,7 @@ https://textual.textualize.io/blog/2022/11/08/version-040/#version-040 - New handler system for messages that doesn't require inheritance - Improved traceback handling +[0.41.0]: https://github.com/Textualize/textual/compare/v0.40.0...v0.41.0 [0.40.0]: https://github.com/Textualize/textual/compare/v0.39.0...v0.40.0 [0.39.0]: https://github.com/Textualize/textual/compare/v0.38.1...v0.39.0 [0.38.1]: https://github.com/Textualize/textual/compare/v0.38.0...v0.38.1 diff --git a/pyproject.toml b/pyproject.toml index ab7e050fd9..72fa4b3a21 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "textual" -version = "0.40.0" +version = "0.41.0" homepage = "https://github.com/Textualize/textual" repository = "https://github.com/Textualize/textual" documentation = "https://textual.textualize.io/"