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..367eab6 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 @@ -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: @@ -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.""" @@ -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 diff --git a/tests/test_cssselect.py b/tests/test_cssselect.py index dc67bb7..241619e 100644 --- a/tests/test_cssselect.py +++ b/tests/test_cssselect.py @@ -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]]" @@ -425,6 +435,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' ) @@ -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)") == ( @@ -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 @@ -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) @@ -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 @@ -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( + '' + ) + 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('') + 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]