From 7b051a65bc358027cd9fec0f890473fd7ad40e99 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Wed, 15 Jul 2026 23:22:25 +0500 Subject: [PATCH 1/5] Fix some :is(), :where() and :matches() bugs. --- cssselect/parser.py | 3 +-- cssselect/xpath.py | 43 +++++++++++++++++++++++++++-------------- tests/test_cssselect.py | 42 +++++++++++++++++++++++++++++++++++++++- 3 files changed, 71 insertions(+), 17 deletions(-) diff --git a/cssselect/parser.py b/cssselect/parser.py index f969769..d0368fb 100644 --- a/cssselect/parser.py +++ b/cssselect/parser.py @@ -739,8 +739,7 @@ def parse_simple_selector_arguments(stream: TokenStream) -> list[Tree]: ) stream.skip_whitespace() next_ = stream.next() - if next_ in (("EOF", None), ("DELIM", ",")): - stream.next() + if next_ == ("DELIM", ","): stream.skip_whitespace() arguments.append(result) elif next_ == ("DELIM", ")"): diff --git a/cssselect/xpath.py b/cssselect/xpath.py index 96eac3f..0be3c1a 100644 --- a/cssselect/xpath.py +++ b/cssselect/xpath.py @@ -37,7 +37,7 @@ ) if TYPE_CHECKING: - from collections.abc import Callable + from collections.abc import Callable, Iterable # typing.Self requires Python 3.11 from typing_extensions import Self @@ -331,22 +331,37 @@ def xpath_relation(self, relation: Relation) -> XPathExpr: return method(xpath, right) def xpath_matching(self, matching: Matching) -> XPathExpr: - xpath = self.xpath(matching.selector) - exprs = [self.xpath(selector) for selector in matching.selector_list] - for e in exprs: - e.add_name_test() - if e.condition: - xpath.add_condition(e.condition, "or") - return xpath + return self._xpath_add_selector_list_condition( + self.xpath(matching.selector), matching.selector_list + ) def xpath_specificityadjustment(self, matching: SpecificityAdjustment) -> XPathExpr: - xpath = self.xpath(matching.selector) - exprs = [self.xpath(selector) for selector in matching.selector_list] - for e in exprs: + return self._xpath_add_selector_list_condition( + self.xpath(matching.selector), matching.selector_list + ) + + def _xpath_add_selector_list_condition( + self, xpath: XPathExpr, selector_list: Iterable[Tree] + ) -> XPathExpr: + """Add a condition matching any selector of the list + (for :is() and :where()).""" + condition = "" + for e in (self.xpath(selector) for selector in selector_list): + if e.path: + # E.g. a :has() argument: it translates to a path, which + # cannot be embedded into a predicate of the outer expression. + raise ExpressionError( + ":has() is not supported inside :is() and :where()" + ) e.add_name_test() - if e.condition: - xpath.add_condition(e.condition, "or") - return xpath + if not e.condition: + # This argument matches any element, so the whole selector + # list does too: it adds no condition. + return xpath + condition = ( + f"({condition}) or ({e.condition})" if condition else e.condition + ) + return xpath.add_condition(condition) def xpath_function(self, function: Function) -> XPathExpr: """Translate a functional pseudo-class.""" diff --git a/tests/test_cssselect.py b/tests/test_cssselect.py index dc67bb7..7f8fc8e 100644 --- a/tests/test_cssselect.py +++ b/tests/test_cssselect.py @@ -170,6 +170,13 @@ def parse_many(first: str, *others: str) -> list[str]: "SpecificityAdjustment[Element[*]:where(Pseudo[Element[*]:hover]," " Pseudo[Element[*]:visited])]" ] + assert parse_many(":is(.foo, .bar)", ":is(.foo,.bar)", ":is(.foo ,.bar)") == [ + "Matching[Element[*]:is(Class[Element[*].foo], Class[Element[*].bar])]" + ] + assert parse_many(":where(.foo, .bar)", ":where(.foo,.bar)") == [ + "SpecificityAdjustment[Element[*]:where(Class[Element[*].foo]," + " Class[Element[*].bar])]" + ] assert parse_many("td ~ th") == ["CombinedSelector[Element[td] ~ Element[th]]"] assert parse_many(":scope > foo") == [ "CombinedSelector[Pseudo[Element[*]:scope] > Element[foo]]" @@ -425,6 +432,10 @@ def get_error(css: str) -> str | None: assert get_error(":where(a b)") == ( "Expected an argument, got " ) + assert get_error(":is(a") == ("Expected an argument, got ") + assert get_error(":is(a,") == ("Expected selector, got ") + assert get_error(":is(a,)") == ("Expected selector, got ") + assert get_error(":where(a") == ("Expected an argument, got ") assert get_error(":scope > div :scope header") == ( 'Got immediate child pseudo-element ":scope" not at the start of a selector' ) @@ -557,6 +568,21 @@ def xpath(css: str) -> str: ) assert xpath("e:where(foo)") == "e[name() = 'foo']" assert xpath("e:where(foo, bar)") == "e[(name() = 'foo') or (name() = 'bar')]" + assert xpath("e:is(.a,.b)") == xpath("e:is(.a, .b)") + assert xpath("e:is(*, .foo)") == "e" + assert xpath("e:where(*, foo)") == "e" + assert xpath("e.foo:is(.a, .b)") == ( + "e[(@class and contains(" + "concat(' ', normalize-space(@class), ' '), ' foo ')) and " + "((@class and contains(" + "concat(' ', normalize-space(@class), ' '), ' a ')) or " + "(@class and contains(" + "concat(' ', normalize-space(@class), ' '), ' b ')))]" + ) + with pytest.raises(ExpressionError): + xpath("e:is(:has(f))") + with pytest.raises(ExpressionError): + xpath("e:where(:has(f))") # Invalid characters in XPath element names assert xpath(r"di\a0 v") == ("*[name() = 'di v']") # di\xa0v @@ -1039,9 +1065,23 @@ def pcss(main: str, *selectors: str, **kwargs: bool) -> list[str]: ] assert pcss("link:has(*)") == [] assert pcss("ol:has(div)") == ["first-ol"] - assert pcss(":is(#first-li, #second-li)") == ["first-li", "second-li"] + assert pcss(":is(#first-li, #second-li)", ":is(#first-li,#second-li)") == [ + "first-li", + "second-li", + ] assert pcss("a:is(#name-anchor, #tag-anchor)") == ["name-anchor", "tag-anchor"] assert pcss(":is(.c)") == ["first-ol", "third-li", "fourth-li"] + assert pcss("li:is(*, .c)") == [ + "first-li", + "second-li", + "third-li", + "fourth-li", + "fifth-li", + "sixth-li", + "seventh-li", + ] + assert pcss("ol.a:is(.nonexistent)") == [] + assert pcss("ol.a:is(.b, .nonexistent)") == ["first-ol"] assert pcss("ol.a.b.c > li.c:nth-child(3)") == ["third-li"] # Invalid characters in XPath element names, should not crash From 469d5ec60f8eb4cf701b4ca22e571dda22553464 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Thu, 16 Jul 2026 01:08:07 +0500 Subject: [PATCH 2/5] Fix is(ns|*), add tests for matches:. --- cssselect/xpath.py | 10 +++++++++- tests/test_cssselect.py | 35 +++++++++++++++++++++++++++++++---- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/cssselect/xpath.py b/cssselect/xpath.py index 0be3c1a..b821ddc 100644 --- a/cssselect/xpath.py +++ b/cssselect/xpath.py @@ -82,7 +82,15 @@ def add_name_test(self) -> None: if self.element == "*": # We weren't doing a test anyway return - self.add_condition(f"name() = {GenericTranslator.xpath_literal(self.element)}") + if self.element.endswith(":*") and is_safe_name(self.element[:-2]): + # A namespace-prefix wildcard like "ns:*" (from the CSS "ns|*"): + # name() is never the literal string "ns:*", so compare with a + # node test instead. + self.add_condition(f"self::{self.element}") + else: + self.add_condition( + f"name() = {GenericTranslator.xpath_literal(self.element)}" + ) self.element = "*" def add_star_prefix(self) -> None: diff --git a/tests/test_cssselect.py b/tests/test_cssselect.py index 7f8fc8e..57bfa32 100644 --- a/tests/test_cssselect.py +++ b/tests/test_cssselect.py @@ -170,9 +170,12 @@ def parse_many(first: str, *others: str) -> list[str]: "SpecificityAdjustment[Element[*]:where(Pseudo[Element[*]:hover]," " Pseudo[Element[*]:visited])]" ] - assert parse_many(":is(.foo, .bar)", ":is(.foo,.bar)", ":is(.foo ,.bar)") == [ - "Matching[Element[*]:is(Class[Element[*].foo], Class[Element[*].bar])]" - ] + assert parse_many( + ":is(.foo, .bar)", + ":is(.foo,.bar)", + ":is(.foo ,.bar)", + ":matches(.foo, .bar)", + ) == ["Matching[Element[*]:is(Class[Element[*].foo], Class[Element[*].bar])]"] assert parse_many(":where(.foo, .bar)", ":where(.foo,.bar)") == [ "SpecificityAdjustment[Element[*]:where(Class[Element[*].foo]," " Class[Element[*].bar])]" @@ -583,6 +586,15 @@ def xpath(css: str) -> str: xpath("e:is(:has(f))") with pytest.raises(ExpressionError): xpath("e:where(:has(f))") + # :matches() is an alias of :is() + assert xpath("e:matches(foo, bar)") == "e[(name() = 'foo') or (name() = 'bar')]" + assert xpath("e:matches(.a, .b)") == xpath("e:is(.a, .b)") + # A namespace-prefix wildcard is a node test, not a literal name + assert xpath("ns|*") == "ns:*" + assert xpath("e:is(ns|*)") == "e[self::ns:*]" + assert xpath("e:where(ns|*)") == "e[self::ns:*]" + assert xpath("*:not(ns|*)") == "*[not(self::ns:*)]" + assert xpath("e:is(ns|f)") == "e[name() = 'ns:f']" # Invalid characters in XPath element names assert xpath(r"di\a0 v") == ("*[name() = 'di v']") # di\xa0v @@ -1070,7 +1082,7 @@ def pcss(main: str, *selectors: str, **kwargs: bool) -> list[str]: "second-li", ] assert pcss("a:is(#name-anchor, #tag-anchor)") == ["name-anchor", "tag-anchor"] - assert pcss(":is(.c)") == ["first-ol", "third-li", "fourth-li"] + assert pcss(":is(.c)", ":matches(.c)") == ["first-ol", "third-li", "fourth-li"] assert pcss("li:is(*, .c)") == [ "first-li", "second-li", @@ -1116,6 +1128,21 @@ def pcss(main: str, *selectors: str, **kwargs: bool) -> list[str]: "checkbox-disabled-checked", ] + def test_select_with_namespace(self) -> None: + document = etree.XML('') + css_to_xpath = GenericTranslator().css_to_xpath + + def pcss(css: str) -> list[str]: + items = typing.cast( + "list[etree._Element]", + document.xpath(css_to_xpath(css), namespaces={"ns": "urn:x"}), + ) + return [element.get("id", "nil") for element in items] + + assert pcss("ns|*") == ["ns-el"] + assert pcss(":is(ns|*)") == ["ns-el"] + assert pcss("*:not(ns|*)") == ["nil", "plain"] + def test_select_shakespeare(self) -> None: document = html.document_fromstring(HTML_SHAKESPEARE) body = typing.cast("list[etree._Element]", document.xpath("//body"))[0] From a2136224cbce89609716842e3b919cc14ce37834 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Fri, 17 Jul 2026 01:45:20 +0500 Subject: [PATCH 3/5] Resolve prefixed names through the namespace mapping in add_name_test(). --- cssselect/xpath.py | 17 ++++++++++++----- tests/test_cssselect.py | 25 ++++++++++++++++++++----- 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/cssselect/xpath.py b/cssselect/xpath.py index b821ddc..d726a34 100644 --- a/cssselect/xpath.py +++ b/cssselect/xpath.py @@ -82,10 +82,12 @@ def add_name_test(self) -> None: if self.element == "*": # We weren't doing a test anyway return - if self.element.endswith(":*") and is_safe_name(self.element[:-2]): - # A namespace-prefix wildcard like "ns:*" (from the CSS "ns|*"): - # name() is never the literal string "ns:*", so compare with a - # node test instead. + prefix, colon, local = self.element.partition(":") + if colon and is_safe_name(prefix) and (local == "*" or is_safe_name(local)): + # A prefixed name like "ns:f" or "ns:*" (from the CSS "ns|f" or + # "ns|*"): name() would compare against the prefix as literally + # written in the document, bypassing the XPath prefix mapping, + # so use a node test instead. self.add_condition(f"self::{self.element}") else: self.add_condition( @@ -449,7 +451,12 @@ def xpath_element(self, selector: Element) -> XPathExpr: safe = safe and bool(is_safe_name(selector.namespace)) xpath = self.xpathexpr_cls(element=element) if not safe: - xpath.add_name_test() + # Not usable as an XPath name test (e.g. an escaped identifier + # like di\a0 v): compare the serialized name instead. Done here + # rather than through add_name_test(), which would mistake a ":" + # inside such a name for a namespace prefix separator. + xpath.add_condition(f"name() = {self.xpath_literal(element)}") + xpath.element = "*" return xpath # CombinedSelector: dispatch by combinator diff --git a/tests/test_cssselect.py b/tests/test_cssselect.py index 57bfa32..35517db 100644 --- a/tests/test_cssselect.py +++ b/tests/test_cssselect.py @@ -589,12 +589,17 @@ def xpath(css: str) -> str: # :matches() is an alias of :is() assert xpath("e:matches(foo, bar)") == "e[(name() = 'foo') or (name() = 'bar')]" assert xpath("e:matches(.a, .b)") == xpath("e:is(.a, .b)") - # A namespace-prefix wildcard is a node test, not a literal name + # A prefixed name is a node test resolved through the namespace + # prefix mapping, not a literal name() comparison assert xpath("ns|*") == "ns:*" assert xpath("e:is(ns|*)") == "e[self::ns:*]" assert xpath("e:where(ns|*)") == "e[self::ns:*]" assert xpath("*:not(ns|*)") == "*[not(self::ns:*)]" - assert xpath("e:is(ns|f)") == "e[name() = 'ns:f']" + assert xpath("e:is(ns|f)") == "e[self::ns:f]" + assert xpath("*:not(ns|f)") == "*[not(self::ns:f)]" + assert xpath("x + ns|f") == ( + "x/following-sibling::*[(self::ns:f) and (position() = 1)]" + ) # Invalid characters in XPath element names assert xpath(r"di\a0 v") == ("*[name() = 'di v']") # di\xa0v @@ -1129,7 +1134,12 @@ def pcss(main: str, *selectors: str, **kwargs: bool) -> list[str]: ] def test_select_with_namespace(self) -> None: - document = etree.XML('') + # The document prefix (n) deliberately differs from the selector + # prefix (ns): matching must go through the namespace mapping, not + # compare the prefixed name as a string. + document = etree.XML( + '' + ) css_to_xpath = GenericTranslator().css_to_xpath def pcss(css: str) -> list[str]: @@ -1139,9 +1149,14 @@ def pcss(css: str) -> list[str]: ) return [element.get("id", "nil") for element in items] - assert pcss("ns|*") == ["ns-el"] - assert pcss(":is(ns|*)") == ["ns-el"] + assert pcss("ns|*") == ["ns-el", "ns-el2"] + assert pcss(":is(ns|*)") == ["ns-el", "ns-el2"] assert pcss("*:not(ns|*)") == ["nil", "plain"] + assert pcss("ns|a") == ["ns-el"] + assert pcss(":is(ns|a)") == ["ns-el"] + assert pcss(":is(b, ns|a)") == ["ns-el", "plain"] + assert pcss("*:not(ns|a)") == ["nil", "plain", "ns-el2"] + assert pcss("b + ns|c") == ["ns-el2"] def test_select_shakespeare(self) -> None: document = html.document_fromstring(HTML_SHAKESPEARE) From 835b9bd9c42194c2da05952a2a62a668f9e29e82 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Fri, 17 Jul 2026 13:04:08 +0500 Subject: [PATCH 4/5] Use node tests for all safe names in add_name_test(). --- cssselect/xpath.py | 12 +++++++----- tests/test_cssselect.py | 38 ++++++++++++++++++++++++++++++++------ 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/cssselect/xpath.py b/cssselect/xpath.py index d726a34..367eab6 100644 --- a/cssselect/xpath.py +++ b/cssselect/xpath.py @@ -83,11 +83,13 @@ def add_name_test(self) -> None: # We weren't doing a test anyway return prefix, colon, local = self.element.partition(":") - if colon and is_safe_name(prefix) and (local == "*" or is_safe_name(local)): - # A prefixed name like "ns:f" or "ns:*" (from the CSS "ns|f" or - # "ns|*"): name() would compare against the prefix as literally - # written in the document, bypassing the XPath prefix mapping, - # so use a node test instead. + if is_safe_name(prefix) and (not colon or local == "*" or is_safe_name(local)): + # A node test, not a name() comparison: name() returns the + # qualified name as written in the document, which would bypass + # the XPath prefix mapping for a prefixed name like "ns:f" or + # "ns:*" (from the CSS "ns|f" or "ns|*"), and would match + # elements in a default namespace for an unprefixed name (which + # the node test emitted for a bare "f" selector does not). self.add_condition(f"self::{self.element}") else: self.add_condition( diff --git a/tests/test_cssselect.py b/tests/test_cssselect.py index 35517db..fba67cd 100644 --- a/tests/test_cssselect.py +++ b/tests/test_cssselect.py @@ -560,7 +560,7 @@ def xpath(css: str) -> str: assert xpath("e f") == ("e/descendant-or-self::*/f") assert xpath("e > f") == ("e/f") assert xpath("e + f") == ( - "e/following-sibling::*[(name() = 'f') and (position() = 1)]" + "e/following-sibling::*[(self::f) and (position() = 1)]" ) assert xpath("e ~ f") == ("e/following-sibling::f") assert xpath("e ~ f:nth-child(3)") == ( @@ -569,8 +569,8 @@ def xpath(css: str) -> str: assert xpath("div#container p") == ( "div[@id = 'container']/descendant-or-self::*/p" ) - assert xpath("e:where(foo)") == "e[name() = 'foo']" - assert xpath("e:where(foo, bar)") == "e[(name() = 'foo') or (name() = 'bar')]" + assert xpath("e:where(foo)") == "e[self::foo]" + assert xpath("e:where(foo, bar)") == "e[(self::foo) or (self::bar)]" assert xpath("e:is(.a,.b)") == xpath("e:is(.a, .b)") assert xpath("e:is(*, .foo)") == "e" assert xpath("e:where(*, foo)") == "e" @@ -587,10 +587,14 @@ def xpath(css: str) -> str: with pytest.raises(ExpressionError): xpath("e:where(:has(f))") # :matches() is an alias of :is() - assert xpath("e:matches(foo, bar)") == "e[(name() = 'foo') or (name() = 'bar')]" + assert xpath("e:matches(foo, bar)") == "e[(self::foo) or (self::bar)]" assert xpath("e:matches(.a, .b)") == xpath("e:is(.a, .b)") - # A prefixed name is a node test resolved through the namespace - # prefix mapping, not a literal name() comparison + # A type selector in predicate position is a node test, not a + # literal name() comparison: a prefixed name must resolve through + # the namespace prefix mapping, and an unprefixed name must not + # match elements in a default namespace (a bare "f" does not). + assert xpath("e:is(f)") == "e[self::f]" + assert xpath("*:not(f)") == "*[not(self::f)]" assert xpath("ns|*") == "ns:*" assert xpath("e:is(ns|*)") == "e[self::ns:*]" assert xpath("e:where(ns|*)") == "e[self::ns:*]" @@ -1158,6 +1162,28 @@ def pcss(css: str) -> list[str]: assert pcss("*:not(ns|a)") == ["nil", "plain", "ns-el2"] assert pcss("b + ns|c") == ["ns-el2"] + def test_select_with_default_namespace(self) -> None: + # A bare "f" translates to an unprefixed XPath node test, which + # only matches elements in *no* namespace, so it cannot see + # elements in a default namespace. Predicate positions (:is(), + # :not(), "+") must agree with that, not compare name() (which + # would match such elements by their unprefixed qualified name). + document = etree.XML('') + css_to_xpath = GenericTranslator().css_to_xpath + + def pcss(css: str) -> list[str]: + items = typing.cast( + "list[etree._Element]", document.xpath(css_to_xpath(css)) + ) + return [element.get("id", "nil") for element in items] + + assert pcss("f") == [] + assert pcss(":is(f)") == [] + assert pcss("x + f") == [] + # ... and since "f" does not match the default-namespaced , + # :not(f) must not exclude it. + assert pcss("*:not(f)") == ["nil", "x", "dns-f"] + def test_select_shakespeare(self) -> None: document = html.document_fromstring(HTML_SHAKESPEARE) body = typing.cast("list[etree._Element]", document.xpath("//body"))[0] From 810101e08bfe9d5554a7a520b29bf976ad953034 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Fri, 17 Jul 2026 13:37:48 +0500 Subject: [PATCH 5/5] Improve add_name_test() test coverage. --- tests/test_cssselect.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/test_cssselect.py b/tests/test_cssselect.py index fba67cd..241619e 100644 --- a/tests/test_cssselect.py +++ b/tests/test_cssselect.py @@ -638,6 +638,29 @@ def xpath(css: str) -> str: with pytest.raises(TypeError): GenericTranslator().selector_to_xpath("foo") # type: ignore[arg-type] + def test_add_name_test(self) -> None: + # Directly exercise XPathExpr.add_name_test(), part of the + # customization API: translation never feeds it a name unusable in + # a node test (xpath_element() compares those with name() itself), + # but a subclass can, and then the name() fallback must be used. + def name_test(element: str) -> str: + xpath = XPathExpr(element=element) + xpath.add_name_test() + return str(xpath) + + # Safe names become node tests, resolved through the namespace + # prefix mapping when prefixed. + assert name_test("f") == "*[self::f]" + assert name_test("ns:f") == "*[self::ns:f]" + assert name_test("ns:*") == "*[self::ns:*]" + # Names not usable in a node test fall back to a name() comparison, + # whether the local name or the prefix is at fault. + assert name_test("di v") == "*[name() = 'di v']" + assert name_test("ns:di v") == "*[name() = 'ns:di v']" + assert name_test("di v:f") == "*[name() = 'di v:f']" + # The universal selector needs no test at all. + assert name_test("*") == "*" + def test_unicode(self) -> None: css = ".a\xc1b" xpath = GenericTranslator().css_to_xpath(css)