From b6b7966ee563a65acb96970bb01248b4e7371355 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Wed, 15 Jul 2026 23:53:45 +0500 Subject: [PATCH 1/5] Fixes for :scope() and :has() parsing and some error paths. --- cssselect/parser.py | 54 ++++++++++++++++++++++------------------- cssselect/xpath.py | 5 ++-- tests/test_cssselect.py | 47 +++++++++++++++++++++++++++++++---- 3 files changed, 74 insertions(+), 32 deletions(-) diff --git a/cssselect/parser.py b/cssselect/parser.py index f969769..83066c0 100644 --- a/cssselect/parser.py +++ b/cssselect/parser.py @@ -613,8 +613,12 @@ def parse_simple_selector( stream.next() result = Class(result, stream.next_ident()) elif peek == ("DELIM", "|"): + # The explicit "no namespace" syntax, e.g. |div: only valid at + # the very start of a simple selector. + if len(stream.used) != selector_start: + raise SelectorSyntaxError(f"Expected selector, got {peek}") stream.next() - result = Element(None, stream.next_ident()) + result = Element(None, stream.next_ident_or_star()) elif peek == ("DELIM", "["): stream.next() result = parse_attrib(result, stream) @@ -637,20 +641,18 @@ def parse_simple_selector( continue if stream.peek() != ("DELIM", "("): result = Pseudo(result, ident) - if repr(result) == "Pseudo[Element[*]:scope]" and not ( - len(stream.used) == 2 - or (len(stream.used) == 3 and stream.used[0].type == "S") - or (len(stream.used) >= 3 and stream.used[-3].is_delim(",")) - or ( - len(stream.used) >= 4 - and stream.used[-3].type == "S" - and stream.used[-4].is_delim(",") - ) - ): - raise SelectorSyntaxError( - 'Got immediate child pseudo-element ":scope" ' - "not at the start of a selector" - ) + if result.ident == "scope": + # :scope is only supported at the start of a selector, + # i.e. the tokens preceding its compound selector must + # be the start of the input or a comma. + preceding = stream.used[:selector_start] + while preceding and preceding[-1].type == "S": + preceding = preceding[:-1] + if preceding and not preceding[-1].is_delim(","): + raise SelectorSyntaxError( + 'Got immediate child pseudo-element ":scope" ' + "not at the start of a selector" + ) continue stream.next() stream.skip_whitespace() @@ -703,12 +705,12 @@ def parse_arguments(stream: TokenStream) -> list[Token]: # noqa: RET503 raise SelectorSyntaxError(f"Expected an argument, got {next_}") -def parse_relative_selector(stream: TokenStream) -> tuple[Token, Selector]: # noqa: RET503 +def parse_relative_selector(stream: TokenStream) -> tuple[Token, Selector]: stream.skip_whitespace() - subselector = "" + subselector_tokens: list[Token] = [] next_ = stream.next() - if next_ in [("DELIM", "+"), ("DELIM", "-"), ("DELIM", ">"), ("DELIM", "~")]: + if next_ in [("DELIM", "+"), ("DELIM", ">"), ("DELIM", "~")]: combinator = next_ stream.skip_whitespace() next_ = stream.next() @@ -716,18 +718,20 @@ def parse_relative_selector(stream: TokenStream) -> tuple[Token, Selector]: # n combinator = Token("DELIM", " ", pos=0) while 1: - if next_.type in ("IDENT", "STRING", "NUMBER") or next_ in [ - ("DELIM", "."), - ("DELIM", "*"), - ]: - subselector += cast("str", next_.value) + if next_.type == "IDENT" or next_ in [("DELIM", "."), ("DELIM", "*")]: + subselector_tokens.append(next_) elif next_ == ("DELIM", ")"): - result = parse(subselector) - return combinator, result[0] + break else: raise SelectorSyntaxError(f"Expected an argument, got {next_}") next_ = stream.next() + # Reparse the collected tokens instead of their concatenated source + # text, so that escaped identifiers are preserved. + subselector_tokens.append(EOFToken(next_.pos)) + result, _ = parse_simple_selector(TokenStream(subselector_tokens)) + return combinator, Selector(result) + def parse_simple_selector_arguments(stream: TokenStream) -> list[Tree]: arguments = [] diff --git a/cssselect/xpath.py b/cssselect/xpath.py index 96eac3f..8510a5d 100644 --- a/cssselect/xpath.py +++ b/cssselect/xpath.py @@ -633,7 +633,7 @@ def xpath_nth_last_of_type_function( self, xpath: XPathExpr, function: Function ) -> XPathExpr: if xpath.element == "*": - raise ExpressionError("*:nth-of-type() is not implemented") + raise ExpressionError("*:nth-last-of-type() is not implemented") return self.xpath_nth_child_function( xpath, function, last=True, add_name_test=False ) @@ -669,7 +669,8 @@ def xpath_root_pseudo(self, xpath: XPathExpr) -> XPathExpr: # for product in response.css('.product'): # description = product.css(':scope > div::text').get() def xpath_scope_pseudo(self, xpath: XPathExpr) -> XPathExpr: - return xpath.add_condition("1") + xpath.add_name_test() + return xpath.add_condition("position() = 1") def xpath_first_child_pseudo(self, xpath: XPathExpr) -> XPathExpr: return xpath.add_condition("count(preceding-sibling::*) = 0") diff --git a/tests/test_cssselect.py b/tests/test_cssselect.py index dc67bb7..6d2bd76 100644 --- a/tests/test_cssselect.py +++ b/tests/test_cssselect.py @@ -91,6 +91,7 @@ def parse_many(first: str, *others: str) -> list[str]: assert parse_many("*|*") == ["Element[*]"] assert parse_many("*|foo") == ["Element[foo]"] assert parse_many("|foo") == ["Element[foo]"] + assert parse_many("|*") == ["Element[*]"] assert parse_many("foo|*") == ["Element[foo|*]"] assert parse_many("foo|bar") == ["Element[foo|bar]"] # This will never match, but it is valid: @@ -185,6 +186,9 @@ def parse_many(first: str, *others: str) -> list[str]: "CombinedSelector[CombinedSelector[Pseudo[Element[*]:scope] > " "Hash[Element[*]#foo]] Hash[Element[*]#bar]]" ] + assert parse_many("*:scope") == ["Pseudo[Element[*]:scope]"] + assert parse_many("div:scope") == ["Pseudo[Element[div]:scope]"] + assert parse_many(".foo:scope") == ["Pseudo[Class[Element[*].foo]:scope]"] def test_pseudo_elements(self) -> None: def parse_pseudo(css: str) -> list[tuple[str, str | None]]: @@ -384,6 +388,9 @@ def get_error(css: str) -> str | None: assert get_error("div > ") == ("Expected selector, got ") assert get_error(" > div") == ("Expected selector, got ' at 2>") assert get_error("foo|#bar") == ("Expected ident or '*', got ") + assert get_error(".foo|bar") == ("Expected selector, got ") + assert get_error("#bar|foo") == ("Expected selector, got ") + assert get_error("[baz]|foo") == ("Expected selector, got ") assert get_error("#.foo") == ("Expected selector, got ") assert get_error(".#foo") == ("Expected ident, got ") assert get_error(":#foo") == ("Expected ident, got ") @@ -431,11 +438,25 @@ def get_error(css: str) -> str | None: assert get_error("div :scope header") == ( 'Got immediate child pseudo-element ":scope" not at the start of a selector' ) + assert get_error("a div:scope") == ( + 'Got immediate child pseudo-element ":scope" not at the start of a selector' + ) + assert get_error("a > .foo:scope") == ( + 'Got immediate child pseudo-element ":scope" not at the start of a selector' + ) + assert get_error("*:scope") is None + assert get_error("div:scope") is None + assert get_error("foo, *:scope") is None assert get_error("> div p") == ("Expected selector, got ' at 0>") # Unsupported :has() with several arguments assert get_error(":has(a, b)") == ("Expected an argument, got ") - assert get_error(":has()") == ("Expected selector, got ") + assert get_error(":has()") == ("Expected selector, got ") + # '-' is not a valid relative combinator + assert get_error(":has(- p)") == ("Expected an argument, got ") + # Strings and numbers are not selectors + assert get_error(':has("a")') == ("Expected an argument, got ") + assert get_error(":has(1)") == ("Expected an argument, got ") def test_translation(self) -> None: def xpath(css: str) -> str: @@ -563,6 +584,18 @@ def xpath(css: str) -> str: assert xpath(r"di\[v") == ("*[name() = 'di[v']") assert xpath(r"[h\a0 ref]") == ("*[attribute::*[name() = 'h ref']]") # h\xa0ref assert xpath(r"[h\]ref]") == ("*[attribute::*[name() = 'h]ref']]") + # Escaped identifiers survive inside :has() arguments + assert xpath(r"e:has(di\[v)") == "e[descendant::*[name() = 'di[v']]" + + # :scope, alone and in a compound selector + assert xpath(":scope") == "*[position() = 1]" + assert xpath("*:scope") == "*[position() = 1]" + assert xpath("div:scope") == "*[(name() = 'div') and (position() = 1)]" + assert xpath(".foo:scope") == ( + "*[(@class and contains(" + "concat(' ', normalize-space(@class), ' '), ' foo ')) " + "and (position() = 1)]" + ) with pytest.raises(ExpressionError): xpath(":fİrst-child") @@ -572,9 +605,9 @@ def xpath(css: str) -> str: xpath(":only-of-type") with pytest.raises(ExpressionError): xpath(":last-of-type") - with pytest.raises(ExpressionError): + with pytest.raises(ExpressionError, match=r"\*:nth-of-type\(\)"): xpath(":nth-of-type(1)") - with pytest.raises(ExpressionError): + with pytest.raises(ExpressionError, match=r"\*:nth-last-of-type\(\)"): xpath(":nth-last-of-type(1)") with pytest.raises(ExpressionError): xpath(":nth-child(n-)") @@ -616,7 +649,7 @@ def test_quoting(self) -> None: '''descendant-or-self::*[@aval = '"""']''' ) assert css_to_xpath(':scope > div[dataimg=""]') == ( - "descendant-or-self::*[1]/div[@dataimg = '']" + "descendant-or-self::*[position() = 1]/div[@dataimg = '']" ) def test_unicode_escapes(self) -> None: @@ -726,7 +759,7 @@ def xpath(css: str) -> str: assert xpath("p img::attr(src)") == ( "descendant-or-self::p/descendant-or-self::*/img/@src" ) - assert xpath(":scope") == "descendant-or-self::*[1]" + assert xpath(":scope") == "descendant-or-self::*[position() = 1]" assert xpath(":first-or-second[href]") == ( "descendant-or-self::*[(@id = 'first' or @id = 'second') and (@href)]" ) @@ -903,6 +936,10 @@ def pcss(main: str, *selectors: str, **kwargs: bool) -> list[str]: assert pcss(":scope body > div") == ["outer-div", "foobar-div"] assert pcss(":scope head") == ["nil"] assert pcss(":scope html") == [] + # Compound :scope matches the scope root only if the rest of the + # compound selector matches it too + assert pcss("html:scope") == ["html"] + assert pcss("div:scope") == [] # --- nth-* and nth-last-* ------------------------------------- From 5ecfa7b6e6c73f7d7b1186500aef120cb2123821 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Thu, 16 Jul 2026 01:10:10 +0500 Subject: [PATCH 2/5] Reject :scope in :is()/:where()/:matches(). --- cssselect/parser.py | 19 ++++++++++++++----- tests/test_cssselect.py | 17 +++++++++++++++++ 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/cssselect/parser.py b/cssselect/parser.py index 83066c0..f0bb493 100644 --- a/cssselect/parser.py +++ b/cssselect/parser.py @@ -574,7 +574,9 @@ def parse_selector(stream: TokenStream) -> tuple[Tree, PseudoElement | None]: def parse_simple_selector( - stream: TokenStream, inside_negation: bool = False + stream: TokenStream, + inside_negation: bool = False, + inside_selector_list: bool = False, ) -> tuple[Tree, PseudoElement | None]: stream.skip_whitespace() selector_start = len(stream.used) @@ -643,12 +645,17 @@ def parse_simple_selector( result = Pseudo(result, ident) if result.ident == "scope": # :scope is only supported at the start of a selector, - # i.e. the tokens preceding its compound selector must - # be the start of the input or a comma. + # i.e. never in :is()/:where()/:matches() arguments + # (where a preceding comma separates arguments, not + # selectors), and otherwise only when the tokens + # preceding its compound selector are the start of the + # input or a comma. preceding = stream.used[:selector_start] while preceding and preceding[-1].type == "S": preceding = preceding[:-1] - if preceding and not preceding[-1].is_delim(","): + if inside_selector_list or ( + preceding and not preceding[-1].is_delim(",") + ): raise SelectorSyntaxError( 'Got immediate child pseudo-element ":scope" ' "not at the start of a selector" @@ -736,7 +743,9 @@ def parse_relative_selector(stream: TokenStream) -> tuple[Token, Selector]: def parse_simple_selector_arguments(stream: TokenStream) -> list[Tree]: arguments = [] while 1: - result, pseudo_element = parse_simple_selector(stream, True) + result, pseudo_element = parse_simple_selector( + stream, inside_negation=True, inside_selector_list=True + ) if pseudo_element: raise SelectorSyntaxError( f"Got pseudo-element ::{pseudo_element} inside function" diff --git a/tests/test_cssselect.py b/tests/test_cssselect.py index 6d2bd76..6d90e4e 100644 --- a/tests/test_cssselect.py +++ b/tests/test_cssselect.py @@ -447,6 +447,23 @@ def get_error(css: str) -> str | None: assert get_error("*:scope") is None assert get_error("div:scope") is None assert get_error("foo, *:scope") is None + # :scope is rejected in :is()/:where()/:matches() arguments too; + # a comma there separates arguments, not selectors. + assert get_error(":is(:scope)") == ( + 'Got immediate child pseudo-element ":scope" not at the start of a selector' + ) + assert get_error(":is(a, :scope)") == ( + 'Got immediate child pseudo-element ":scope" not at the start of a selector' + ) + assert get_error(":where(a, :scope)") == ( + 'Got immediate child pseudo-element ":scope" not at the start of a selector' + ) + assert get_error(":matches(a, :scope)") == ( + 'Got immediate child pseudo-element ":scope" not at the start of a selector' + ) + assert get_error("foo, :is(a, :scope)") == ( + 'Got immediate child pseudo-element ":scope" not at the start of a selector' + ) assert get_error("> div p") == ("Expected selector, got ' at 0>") # Unsupported :has() with several arguments From 85b43f6eb98d631bb58f5b7d68898bc52209854d Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Fri, 17 Jul 2026 01:50:52 +0500 Subject: [PATCH 3/5] Fix two misleading parser error messages. --- cssselect/parser.py | 7 +++++-- tests/test_cssselect.py | 29 ++++++++++++++++++++--------- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/cssselect/parser.py b/cssselect/parser.py index f0bb493..59cb06b 100644 --- a/cssselect/parser.py +++ b/cssselect/parser.py @@ -657,13 +657,16 @@ def parse_simple_selector( preceding and not preceding[-1].is_delim(",") ): raise SelectorSyntaxError( - 'Got immediate child pseudo-element ":scope" ' - "not at the start of a selector" + 'Got pseudo-class ":scope" not at the start of a selector' ) continue stream.next() stream.skip_whitespace() if ident.lower() == "not": + if inside_selector_list: + raise SelectorSyntaxError( + ":not() is not supported inside :is(), :where() and :matches()" + ) if inside_negation: raise SelectorSyntaxError("Got nested :not()") argument, argument_pseudo_element = parse_simple_selector( diff --git a/tests/test_cssselect.py b/tests/test_cssselect.py index 6d90e4e..f6a604f 100644 --- a/tests/test_cssselect.py +++ b/tests/test_cssselect.py @@ -422,6 +422,17 @@ def get_error(css: str) -> str | None: "Got pseudo-element ::before inside :not() at 12" ) assert get_error(":not(:not(a))") == ("Got nested :not()") + # A :not() inside :is()/:where()/:matches() is not a nested :not() + # and gets its own message + assert get_error(":is(:not(a))") == ( + ":not() is not supported inside :is(), :where() and :matches()" + ) + assert get_error(":where(:not(a))") == ( + ":not() is not supported inside :is(), :where() and :matches()" + ) + assert get_error(":matches(:not(a))") == ( + ":not() is not supported inside :is(), :where() and :matches()" + ) assert get_error(":is(:before)") == ( "Got pseudo-element ::before inside function" ) @@ -433,16 +444,16 @@ def get_error(css: str) -> str | None: "Expected an argument, got " ) assert get_error(":scope > div :scope header") == ( - 'Got immediate child pseudo-element ":scope" not at the start of a selector' + 'Got pseudo-class ":scope" not at the start of a selector' ) assert get_error("div :scope header") == ( - 'Got immediate child pseudo-element ":scope" not at the start of a selector' + 'Got pseudo-class ":scope" not at the start of a selector' ) assert get_error("a div:scope") == ( - 'Got immediate child pseudo-element ":scope" not at the start of a selector' + 'Got pseudo-class ":scope" not at the start of a selector' ) assert get_error("a > .foo:scope") == ( - 'Got immediate child pseudo-element ":scope" not at the start of a selector' + 'Got pseudo-class ":scope" not at the start of a selector' ) assert get_error("*:scope") is None assert get_error("div:scope") is None @@ -450,19 +461,19 @@ def get_error(css: str) -> str | None: # :scope is rejected in :is()/:where()/:matches() arguments too; # a comma there separates arguments, not selectors. assert get_error(":is(:scope)") == ( - 'Got immediate child pseudo-element ":scope" not at the start of a selector' + 'Got pseudo-class ":scope" not at the start of a selector' ) assert get_error(":is(a, :scope)") == ( - 'Got immediate child pseudo-element ":scope" not at the start of a selector' + 'Got pseudo-class ":scope" not at the start of a selector' ) assert get_error(":where(a, :scope)") == ( - 'Got immediate child pseudo-element ":scope" not at the start of a selector' + 'Got pseudo-class ":scope" not at the start of a selector' ) assert get_error(":matches(a, :scope)") == ( - 'Got immediate child pseudo-element ":scope" not at the start of a selector' + 'Got pseudo-class ":scope" not at the start of a selector' ) assert get_error("foo, :is(a, :scope)") == ( - 'Got immediate child pseudo-element ":scope" not at the start of a selector' + 'Got pseudo-class ":scope" not at the start of a selector' ) assert get_error("> div p") == ("Expected selector, got ' at 0>") From 5b7e7b9a02d1bf210ea46cfba458238754c2b4b7 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Fri, 17 Jul 2026 13:21:32 +0500 Subject: [PATCH 4/5] Allow trailing whitespace in :has() arguments. --- cssselect/parser.py | 10 +++++++++- tests/test_cssselect.py | 6 ++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/cssselect/parser.py b/cssselect/parser.py index 59cb06b..42df281 100644 --- a/cssselect/parser.py +++ b/cssselect/parser.py @@ -727,8 +727,16 @@ def parse_relative_selector(stream: TokenStream) -> tuple[Token, Selector]: else: combinator = Token("DELIM", " ", pos=0) + seen_whitespace = False while 1: - if next_.type == "IDENT" or next_ in [("DELIM", "."), ("DELIM", "*")]: + if next_.type == "S": + # Whitespace is valid before the closing parenthesis; anywhere + # else it would be a descendant combinator, which is not + # supported in :has() arguments. + seen_whitespace = True + elif next_.type == "IDENT" or next_ in [("DELIM", "."), ("DELIM", "*")]: + if seen_whitespace: + raise SelectorSyntaxError(f"Expected an argument, got {next_}") subselector_tokens.append(next_) elif next_ == ("DELIM", ")"): break diff --git a/tests/test_cssselect.py b/tests/test_cssselect.py index f6a604f..69b5b27 100644 --- a/tests/test_cssselect.py +++ b/tests/test_cssselect.py @@ -480,11 +480,17 @@ def get_error(css: str) -> str | None: # Unsupported :has() with several arguments assert get_error(":has(a, b)") == ("Expected an argument, got ") assert get_error(":has()") == ("Expected selector, got ") + assert get_error(":has(a b)") == ("Expected an argument, got ") + assert get_error(":has(a .b)") == ("Expected an argument, got ") # '-' is not a valid relative combinator assert get_error(":has(- p)") == ("Expected an argument, got ") # Strings and numbers are not selectors assert get_error(':has("a")') == ("Expected an argument, got ") assert get_error(":has(1)") == ("Expected an argument, got ") + # Whitespace around a :has() argument is not a combinator + assert get_error("e:has(f )") is None + assert get_error("e:has( > f )") is None + assert get_error(":has(.a )") is None def test_translation(self) -> None: def xpath(css: str) -> str: From 12467f093c6ad82b353e9acacd46e58cf31f4740 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Fri, 17 Jul 2026 13:01:26 +0500 Subject: [PATCH 5/5] Parse the An+B microsyntax case-insensitively. --- cssselect/parser.py | 3 ++- tests/test_cssselect.py | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/cssselect/parser.py b/cssselect/parser.py index 42df281..9966d07 100644 --- a/cssselect/parser.py +++ b/cssselect/parser.py @@ -830,7 +830,8 @@ def parse_series(tokens: Iterable[Token]) -> tuple[int, int]: for token in tokens: if token.type == "STRING": raise ValueError("String tokens not allowed in series.") - s = "".join(cast("str", token.value) for token in tokens).strip() + # The An+B microsyntax is ASCII-case-insensitive: 2N+1, EVEN, Odd... + s = ascii_lower("".join(cast("str", token.value) for token in tokens).strip()) if s == "odd": return 2, 1 if s == "even": diff --git a/tests/test_cssselect.py b/tests/test_cssselect.py index 69b5b27..10315f7 100644 --- a/tests/test_cssselect.py +++ b/tests/test_cssselect.py @@ -842,6 +842,12 @@ def series(css: str) -> tuple[int, int] | None: assert series("5") == (0, 5) assert series("foo") is None assert series("n+") is None + # ASCII-case-insensitive + assert series("2N+1") == (2, 1) + assert series("EVEN") == (2, 0) + assert series("Odd") == (2, 1) + assert series("N") == (1, 0) + assert series("-N+3") == (-1, 3) def test_lang(self) -> None: document = etree.fromstring(XMLLANG_IDS)