Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 13 additions & 26 deletions cssselect/xpath.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,29 +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
self.element = other.element
self.condition = other.condition
return self


Expand Down Expand Up @@ -455,33 +440,35 @@ 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="]")
return left.add_condition(f"./{right}")

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.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="]")
return left.add_condition(f"following-sibling::{right}")

# Function: dispatch by function/pseudo-class name

Expand Down
80 changes: 80 additions & 0 deletions tests/test_cssselect.py
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,59 @@ 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 ')) 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") == (
Expand Down Expand Up @@ -1039,6 +1092,33 @@ 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("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"]
Expand Down
Loading