From 6c7acbcab517089b629c4d8d06bc17bd13cdc2dd Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Wed, 15 Jul 2026 22:47:32 +0500 Subject: [PATCH 1/2] Fix some :has() bugs. --- cssselect/xpath.py | 20 ++++++++++++++++---- tests/test_cssselect.py | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/cssselect/xpath.py b/cssselect/xpath.py index 96eac3f..61568db 100644 --- a/cssselect/xpath.py +++ b/cssselect/xpath.py @@ -115,6 +115,8 @@ def join( self.element += "[" + other.condition + "]" if closing_combiner: self.element += closing_combiner + # Any condition of self is already in `path`. + self.condition = "" return self @@ -467,21 +469,31 @@ def xpath_relation_child_combinator( self, left: XPathExpr, right: XPathExpr ) -> XPathExpr: """right is an immediate child of left; select left""" - return left.join("[./", right, closing_combiner="]") + return left.join("[./", right, closing_combiner="]", has_inner_condition=True) def xpath_relation_direct_adjacent_combinator( self, left: XPathExpr, right: XPathExpr ) -> XPathExpr: """right is a sibling immediately after left; select left""" - return left.add_condition( - f"following-sibling::*[(name() = '{right.element}') and (position() = 1)]" + right.add_name_test() + right.add_condition("position() = 1") + return left.join( + "[following-sibling::", + right, + closing_combiner="]", + has_inner_condition=True, ) def xpath_relation_indirect_adjacent_combinator( self, left: XPathExpr, right: XPathExpr ) -> XPathExpr: """right is a sibling after left, immediately or not; select left""" - return left.join("[following-sibling::", right, closing_combiner="]") + return left.join( + "[following-sibling::", + right, + closing_combiner="]", + has_inner_condition=True, + ) # Function: dispatch by function/pseudo-class name diff --git a/tests/test_cssselect.py b/tests/test_cssselect.py index dc67bb7..10b5fc3 100644 --- a/tests/test_cssselect.py +++ b/tests/test_cssselect.py @@ -532,6 +532,33 @@ def xpath(css: str) -> str: xpath("e:has(+ f)") == "e[following-sibling::*[(name() = 'f') and (position() = 1)]]" ) + assert xpath("e:has(> f.bar)") == ( + "e[./f[@class and contains(" + "concat(' ', normalize-space(@class), ' '), ' bar ')]]" + ) + assert xpath("e:has(> .bar)") == ( + "e[./*[@class and contains(" + "concat(' ', normalize-space(@class), ' '), ' bar ')]]" + ) + assert xpath("e:has(~ f.bar)") == ( + "e[following-sibling::f[@class and contains(" + "concat(' ', normalize-space(@class), ' '), ' bar ')]]" + ) + assert xpath("e:has(+ f.bar)") == ( + "e[following-sibling::*[((@class and contains(" + "concat(' ', normalize-space(@class), ' '), ' bar ')) " + "and (name() = 'f')) and (position() = 1)]]" + ) + assert xpath("e:has(+ .bar)") == ( + "e[following-sibling::*[(@class and contains(" + "concat(' ', normalize-space(@class), ' '), ' bar ')) " + "and (position() = 1)]]" + ) + assert xpath("e:has(+ *)") == "e[following-sibling::*[position() = 1]]" + assert xpath("e.foo:has(f)") == ( + "e[@class and contains(" + "concat(' ', normalize-space(@class), ' '), ' foo ')][descendant::f]" + ) assert xpath('e:contains("foo")') == ("e[contains(., 'foo')]") assert xpath("e:ConTains(foo)") == ("e[contains(., 'foo')]") assert xpath("e.warning") == ( @@ -1039,6 +1066,19 @@ def pcss(main: str, *selectors: str, **kwargs: bool) -> list[str]: ] assert pcss("link:has(*)") == [] assert pcss("ol:has(div)") == ["first-ol"] + assert pcss("ol:has(> div)") == [] + assert pcss("ol:has(> li.c)") == ["first-ol"] + assert pcss("li:has(> div)") == ["second-li"] + assert pcss("li:has(+ li.c)") == ["second-li", "third-li"] + assert pcss("li:has(~ li.c)") == ["first-li", "second-li", "third-li"] + assert pcss("li:has(+ *)") == [ + "first-li", + "second-li", + "third-li", + "fourth-li", + "fifth-li", + "sixth-li", + ] assert pcss(":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"] From fba5de98853dea526e071bad4b8587e41f3981f8 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Thu, 16 Jul 2026 01:04:47 +0500 Subject: [PATCH 2/2] Rewrite :has() to use conditions instead of string concats. --- cssselect/xpath.py | 47 ++++++++++------------------------------- tests/test_cssselect.py | 44 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 53 insertions(+), 38 deletions(-) diff --git a/cssselect/xpath.py b/cssselect/xpath.py index 61568db..d6ca8a0 100644 --- a/cssselect/xpath.py +++ b/cssselect/xpath.py @@ -92,31 +92,14 @@ def add_star_prefix(self) -> None: """ self.path += "*/" - def join( - self, - combiner: str, - other: XPathExpr, - closing_combiner: str | None = None, - has_inner_condition: bool = False, - ) -> Self: + def join(self, combiner: str, other: XPathExpr) -> Self: path = str(self) + combiner # Any "star prefix" is redundant when joining. if other.path != "*/": path += other.path self.path = path - if not has_inner_condition: - self.element = ( - other.element + closing_combiner if closing_combiner else other.element - ) - self.condition = other.condition - else: - self.element = other.element - if other.condition: - self.element += "[" + other.condition + "]" - if closing_combiner: - self.element += closing_combiner - # Any condition of self is already in `path`. - self.condition = "" + self.element = other.element + self.condition = other.condition return self @@ -457,19 +440,21 @@ def xpath_indirect_adjacent_combinator( """right is a sibling after left, immediately or not""" return left.join("/following-sibling::", right) + # The relative selector is kept in `condition` (instead of being folded + # into `path`/`element`) so that `element` stays a plain element name: + # later steps such as :first-of-type or :not() read and rewrite it. + def xpath_relation_descendant_combinator( self, left: XPathExpr, right: XPathExpr ) -> XPathExpr: """right is a child, grand-child or further descendant of left; select left""" - return left.join( - "[descendant::", right, closing_combiner="]", has_inner_condition=True - ) + return left.add_condition(f"descendant::{right}") def xpath_relation_child_combinator( self, left: XPathExpr, right: XPathExpr ) -> XPathExpr: """right is an immediate child of left; select left""" - return left.join("[./", right, closing_combiner="]", has_inner_condition=True) + return left.add_condition(f"./{right}") def xpath_relation_direct_adjacent_combinator( self, left: XPathExpr, right: XPathExpr @@ -477,23 +462,13 @@ def xpath_relation_direct_adjacent_combinator( """right is a sibling immediately after left; select left""" right.add_name_test() right.add_condition("position() = 1") - return left.join( - "[following-sibling::", - right, - closing_combiner="]", - has_inner_condition=True, - ) + return left.add_condition(f"following-sibling::{right}") def xpath_relation_indirect_adjacent_combinator( self, left: XPathExpr, right: XPathExpr ) -> XPathExpr: """right is a sibling after left, immediately or not; select left""" - return left.join( - "[following-sibling::", - right, - closing_combiner="]", - has_inner_condition=True, - ) + return left.add_condition(f"following-sibling::{right}") # Function: dispatch by function/pseudo-class name diff --git a/tests/test_cssselect.py b/tests/test_cssselect.py index 10b5fc3..4782bcf 100644 --- a/tests/test_cssselect.py +++ b/tests/test_cssselect.py @@ -556,9 +556,35 @@ def xpath(css: str) -> str: ) assert xpath("e:has(+ *)") == "e[following-sibling::*[position() = 1]]" assert xpath("e.foo:has(f)") == ( - "e[@class and contains(" - "concat(' ', normalize-space(@class), ' '), ' foo ')][descendant::f]" + "e[(@class and contains(" + "concat(' ', normalize-space(@class), ' '), ' foo ')) and (descendant::f)]" + ) + # Negating :has(): the relative selector must be kept as a predicate, + # not turned into a literal name test. + assert xpath("e:not(:has(a))") == "e[not(descendant::a)]" + assert xpath("e:not(:has(> a))") == "e[not(./a)]" + assert xpath("e:not(:has(~ a))") == "e[not(following-sibling::a)]" + assert xpath("e:not(:has(+ a))") == ( + "e[not(following-sibling::*[(name() = 'a') and (position() = 1)])]" + ) + # Element-reading pseudo-classes chained after :has() + assert xpath("e:has(f):first-of-type") == ( + "e[(descendant::f) and (count(preceding-sibling::e) = 0)]" + ) + assert xpath("e:has(f):last-of-type") == ( + "e[(descendant::f) and (count(following-sibling::e) = 0)]" ) + assert xpath("e:has(f):nth-of-type(2)") == ( + "e[(descendant::f) and (count(preceding-sibling::e) = 1)]" + ) + assert xpath("e:has(f):nth-last-of-type(2)") == ( + "e[(descendant::f) and (count(following-sibling::e) = 1)]" + ) + assert xpath("e:has(f):only-of-type") == ( + "e[(descendant::f) and (count(parent::*/child::e) = 1)]" + ) + with pytest.raises(ExpressionError): + xpath("*:has(f):first-of-type") assert xpath('e:contains("foo")') == ("e[contains(., 'foo')]") assert xpath("e:ConTains(foo)") == ("e[contains(., 'foo')]") assert xpath("e.warning") == ( @@ -1079,6 +1105,20 @@ def pcss(main: str, *selectors: str, **kwargs: bool) -> list[str]: "fifth-li", "sixth-li", ] + assert pcss("ol:not(:has(div))") == ["second-ol"] + assert pcss("ol:not(:has(> div))") == ["first-ol", "second-ol"] + assert pcss("li:not(:has(div))") == [ + "first-li", + "third-li", + "fourth-li", + "fifth-li", + "sixth-li", + "seventh-li", + ] + assert pcss("li:has(div):nth-of-type(2)") == ["second-li"] + assert pcss("li:has(div):first-of-type") == [] + assert pcss("ol:has(li):first-of-type") == ["first-ol"] + assert pcss("p:has(b):only-of-type") == ["paragraph"] assert pcss(":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"]