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
3 changes: 1 addition & 2 deletions cssselect/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", ")"):
Expand Down
64 changes: 48 additions & 16 deletions cssselect/xpath.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -82,7 +82,19 @@ 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)}")
prefix, colon, local = self.element.partition(":")
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(
f"name() = {GenericTranslator.xpath_literal(self.element)}"
)
self.element = "*"

def add_star_prefix(self) -> None:
Expand Down Expand Up @@ -331,22 +343,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."""
Expand Down Expand Up @@ -426,7 +453,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
Expand Down
141 changes: 136 additions & 5 deletions tests/test_cssselect.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,16 @@ 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)",
":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])]"
]
assert parse_many("td ~ th") == ["CombinedSelector[Element[td] ~ Element[th]]"]
assert parse_many(":scope > foo") == [
"CombinedSelector[Pseudo[Element[*]:scope] > Element[foo]]"
Expand Down Expand Up @@ -425,6 +435,10 @@ def get_error(css: str) -> str | None:
assert get_error(":where(a b)") == (
"Expected an argument, got <IDENT 'b' at 9>"
)
assert get_error(":is(a") == ("Expected an argument, got <EOF at 5>")
assert get_error(":is(a,") == ("Expected selector, got <EOF at 6>")
assert get_error(":is(a,)") == ("Expected selector, got <DELIM ')' at 6>")
assert get_error(":where(a") == ("Expected an argument, got <EOF at 8>")
assert get_error(":scope > div :scope header") == (
'Got immediate child pseudo-element ":scope" not at the start of a selector'
)
Expand Down Expand Up @@ -546,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)") == (
Expand All @@ -555,8 +569,41 @@ 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"
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))")
# :matches() is an alias of :is()
assert xpath("e:matches(foo, bar)") == "e[(self::foo) or (self::bar)]"
assert xpath("e:matches(.a, .b)") == xpath("e:is(.a, .b)")
# 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:*]"
assert xpath("*:not(ns|*)") == "*[not(self::ns:*)]"
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
Expand Down Expand Up @@ -591,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)
Expand Down Expand Up @@ -1039,9 +1109,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(":is(.c)", ":matches(.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
Expand Down Expand Up @@ -1076,6 +1160,53 @@ def pcss(main: str, *selectors: str, **kwargs: bool) -> list[str]:
"checkbox-disabled-checked",
]

def test_select_with_namespace(self) -> None:
# 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(
'<r xmlns:n="urn:x"><n:a id="ns-el"/><b id="plain"/><n:c id="ns-el2"/></r>'
)
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", "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_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('<r xmlns="urn:d"><x id="x"/><f id="dns-f"/></r>')
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 <f>,
# :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]
Expand Down
Loading