diff --git a/cssselect/xpath.py b/cssselect/xpath.py
index c46c500..3170831 100644
--- a/cssselect/xpath.py
+++ b/cssselect/xpath.py
@@ -423,20 +423,30 @@ def xpath_attrib(self, selector: Attrib) -> XPathExpr:
name = ascii_lower(selector.attrib)
else:
name = selector.attrib
+ safe = is_safe_name(name)
+ literal = self.xpath_literal(name)
if selector.namespace == "*":
# Namespace wildcard, e.g. "[*|href]": any namespace, including
# none. XPath 1.0 has no "*:name" name test for this, so match
# by local name instead.
- attrib = f"attribute::*[local-name() = {self.xpath_literal(name)}]"
+ attrib = f"attribute::*[local-name() = {literal}]"
+ elif not selector.namespace and safe:
+ attrib = "@" + name
+ elif not selector.namespace:
+ # Like for elements, a name unusable in an XPath name test is
+ # matched by local name and the lack of a namespace.
+ attrib = f"attribute::*[local-name() = {literal} and namespace-uri() = '']"
+ elif not is_safe_name(selector.namespace):
+ # A prefix unusable in an XPath name test cannot be resolved
+ # through the prefix mapping: compare the serialized name instead.
+ qualified = f"{selector.namespace}:{name}"
+ attrib = f"attribute::*[name() = {self.xpath_literal(qualified)}]"
+ elif safe:
+ attrib = f"@{selector.namespace}:{name}"
else:
- safe = is_safe_name(name)
- if selector.namespace:
- name = f"{selector.namespace}:{name}"
- safe = safe and is_safe_name(selector.namespace)
- if safe:
- attrib = "@" + name
- else:
- attrib = f"attribute::*[name() = {self.xpath_literal(name)}]"
+ # Test the local name within the mapped namespace: name() would
+ # compare the prefix as written in the document instead.
+ attrib = f"attribute::{selector.namespace}:*[local-name() = {literal}]"
if selector.value is None:
value = None
elif self.lower_case_attribute_values:
@@ -494,19 +504,27 @@ def xpath_element(self, selector: Element) -> XPathExpr:
xpath = self.xpathexpr_cls(element="*")
xpath.add_condition(f"local-name() = {self.xpath_literal(element)}")
return xpath
+ prefix = ""
if selector.namespace and selector.namespace != "*":
# Namespace prefixes are case-sensitive.
# http://www.w3.org/TR/css3-namespace/#prefixes
- element = f"{selector.namespace}:{element}"
- safe = safe and bool(is_safe_name(selector.namespace))
- xpath = self.xpathexpr_cls(element=element)
- if not safe:
- # 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 = "*"
+ prefix = f"{selector.namespace}:"
+ if not is_safe_name(selector.namespace):
+ # A prefix not usable in an XPath name test cannot be
+ # resolved through the prefix mapping: compare the
+ # serialized name instead.
+ xpath = self.xpathexpr_cls(element="*")
+ xpath.add_condition(f"name() = {self.xpath_literal(prefix + element)}")
+ return xpath
+ if safe:
+ return self.xpathexpr_cls(element=prefix + element)
+ # A name unusable in an XPath name test (e.g. di\a0 v) is matched by
+ # local name within the prefix, or by the lack of a namespace.
+ xpath = self.xpathexpr_cls(element=f"{prefix}*")
+ condition = f"local-name() = {self.xpath_literal(element)}"
+ if not prefix:
+ condition += " and namespace-uri() = ''"
+ xpath.add_condition(condition)
return xpath
# CombinedSelector: dispatch by combinator
diff --git a/tests/test_cssselect.py b/tests/test_cssselect.py
index 266bbca..2a88c92 100644
--- a/tests/test_cssselect.py
+++ b/tests/test_cssselect.py
@@ -992,21 +992,41 @@ def xpath(css: str) -> str:
)
# Invalid characters in XPath element names
- assert xpath(r"di\a0 v") == ("*[name() = 'di v']") # di\xa0v
- assert xpath(r"di\[v") == ("*[name() = 'di[v']")
- assert xpath(r"[h\a0 ref]") == ("*[attribute::*[name() = 'h ref']]") # h\xa0ref
- assert xpath(r"[h\]ref]") == ("*[attribute::*[name() = 'h]ref']]")
+ assert xpath(r"di\a0 v") == ( # di\xa0v
+ "*[local-name() = 'di v' and namespace-uri() = '']"
+ )
+ assert xpath(r"di\[v") == ("*[local-name() = 'di[v' and namespace-uri() = '']")
+ assert xpath(r"ns|di\[v") == ("ns:*[local-name() = 'di[v']")
+ assert xpath(r"[ns|h\]ref]") == ("*[attribute::ns:*[local-name() = 'h]ref']]")
+ assert xpath(r"[h\a0 ref]") == ( # h\xa0ref
+ "*[attribute::*[local-name() = 'h ref' and namespace-uri() = '']]"
+ )
+ assert xpath(r"[h\]ref]") == (
+ "*[attribute::*[local-name() = 'h]ref' and namespace-uri() = '']]"
+ )
+ # An escaped ":" is part of the name, not a prefix: no match for a
+ # namespaced p:ref attribute either.
+ assert xpath(r"[p\:ref]") == (
+ "*[attribute::*[local-name() = 'p:ref' and namespace-uri() = '']]"
+ )
# A trailing newline is not a name character either
- assert xpath(r"div\a ") == "*[name() = 'div\n']"
- assert xpath(r"[href\a ]") == "*[attribute::*[name() = 'href\n']]"
+ assert xpath(r"div\a ") == "*[local-name() = 'div\n' and namespace-uri() = '']"
+ assert xpath(r"[href\a ]") == (
+ "*[attribute::*[local-name() = 'href\n' and namespace-uri() = '']]"
+ )
assert xpath(r"ns\a |div") == "*[name() = 'ns\n:div']"
+ assert xpath(r"[ns\a |href]") == "*[attribute::*[name() = 'ns\n:href']]"
# An escaped "*" is a name, not the universal selector or wildcard
- assert xpath(r"\*") == "*[name() = '*']"
- assert xpath(r"\2a") == "*[name() = '*']"
- assert xpath(r"e > \*") == "e/*[name() = '*']"
- assert xpath(r"[\*]") == "*[attribute::*[name() = '*']]"
+ assert xpath(r"\*") == "*[local-name() = '*' and namespace-uri() = '']"
+ assert xpath(r"\2a") == "*[local-name() = '*' and namespace-uri() = '']"
+ assert xpath(r"e > \*") == "e/*[local-name() = '*' and namespace-uri() = '']"
+ assert xpath(r"[\*]") == (
+ "*[attribute::*[local-name() = '*' and namespace-uri() = '']]"
+ )
# Escaped identifiers survive inside :has() arguments
- assert xpath(r"e:has(di\[v)") == "e[descendant::*[name() = 'di[v']]"
+ assert xpath(r"e:has(di\[v)") == (
+ "e[descendant::*[local-name() = 'di[v' and namespace-uri() = '']]"
+ )
# :scope, alone and in a compound selector
assert xpath(":scope") == "*[position() = 1]"
@@ -1109,7 +1129,7 @@ class LowerValues(GenericTranslator):
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),
+ # a node test (xpath_element() tests those by local name itself),
# but a subclass can, and then the name() fallback must be used.
def name_test(element: str) -> str:
xpath = XPathExpr(element=element)
@@ -1173,7 +1193,9 @@ def test_unicode_escapes(self) -> None:
"""descendant-or-self::*[@aval = "' '"]"""
)
# A code point beyond the Unicode range is replaced with U+FFFD.
- assert css_to_xpath(r"\110000") == ("descendant-or-self::*[name() = '\ufffd']")
+ assert css_to_xpath(r"\110000") == (
+ "descendant-or-self::*[local-name() = '\ufffd' and namespace-uri() = '']"
+ )
# A null code point is likewise replaced with U+FFFD, so no NUL
# character can leak into the generated XPath string.
assert css_to_xpath(r"*[aval='\0 ']") == (
@@ -1449,27 +1471,27 @@ def argument_types(css: str) -> list[str]:
def test_html_non_ascii_element_names(self) -> None:
xpath = HTMLTranslator().css_to_xpath
- assert xpath("x-Ä") == "descendant-or-self::*[name() = 'x-Ä']"
- assert xpath("X-Ä") == "descendant-or-self::*[name() = 'x-Ä']"
+ xpath_x_a = (
+ "descendant-or-self::*[local-name() = 'x-Ä' and namespace-uri() = '']"
+ )
+ assert xpath("x-Ä") == xpath_x_a
+ assert xpath("X-Ä") == xpath_x_a
assert xpath("*|X-Ä") == "descendant-or-self::*[local-name() = 'x-Ä']"
- assert xpath("X-ä") == "descendant-or-self::*[name() = 'x-ä']"
+ assert xpath("X-ä") == (
+ "descendant-or-self::*[local-name() = 'x-ä' and namespace-uri() = '']"
+ )
def test_html_non_ascii_attribute_names(self) -> None:
xpath = HTMLTranslator().css_to_xpath
- assert (
- xpath("[DATA-Ä]")
- == "descendant-or-self::*[attribute::*[name() = 'data-Ä']]"
- )
- assert (
- xpath('[DATA-Ä="yes"]')
- == "descendant-or-self::*[attribute::*[name() = 'data-Ä'] = 'yes']"
- )
+ data_a = "attribute::*[local-name() = 'data-Ä' and namespace-uri() = '']"
+ assert xpath("[DATA-Ä]") == f"descendant-or-self::*[{data_a}]"
+ assert xpath('[DATA-Ä="yes"]') == f"descendant-or-self::*[{data_a} = 'yes']"
assert xpath("[*|DATA-Ä]") == (
"descendant-or-self::*[attribute::*[local-name() = 'data-Ä']]"
)
- assert (
- xpath('[DATA-ä="yes"]')
- == "descendant-or-self::*[attribute::*[name() = 'data-ä'] = 'yes']"
+ assert xpath('[DATA-ä="yes"]') == (
+ "descendant-or-self::*"
+ "[attribute::*[local-name() = 'data-ä' and namespace-uri() = ''] = 'yes']"
)
def test_html_non_ascii_lang(self) -> None:
@@ -1864,6 +1886,20 @@ def pcss(css: str) -> list[str]:
assert pcss("*:not(ns|a)") == ["nil", "plain", "ns-el2"]
assert pcss("b + ns|c") == ["ns-el2"]
+ # Names unusable in an XPath node test (here non-ASCII) must go
+ # through the namespace mapping too, not compare name() as a string.
+ document = etree.XML(
+ '<ä id="plain" ö="1"/>'
+ )
+ assert pcss("ns|ä") == ["ns-el"]
+ assert pcss("ä") == ["plain"]
+ assert pcss("*:not(ns|ä)") == ["nil", "plain"]
+ assert pcss("[ns|ö]") == ["ns-el"]
+ assert pcss("[ö]") == ["plain"]
+ # An escaped ":" is part of the attribute name, not a prefix, so this
+ # must not match the namespaced n:ö attribute by its name().
+ assert pcss(r"[n\:ö]") == []
+
def test_select_with_wildcard_attribute_values(self) -> None:
cases = [
("=", "prefix-token", "prefix-token"),
@@ -1942,7 +1978,9 @@ def test_select_with_default_namespace(self) -> None:
# 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('')
+ document = etree.XML(
+ '<ä id="dns-ä"/>'
+ )
css_to_xpath = GenericTranslator().css_to_xpath
def pcss(css: str) -> list[str]:
@@ -1956,7 +1994,13 @@ def pcss(css: str) -> list[str]:
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"]
+ assert pcss("*:not(f)") == ["nil", "x", "dns-f", "dns-ä"]
+ # A name unusable in an XPath node test (here non-ASCII) must follow
+ # the same rule, not match the unprefixed name() of such an element.
+ assert pcss("ä") == []
+ assert pcss(":is(ä)") == []
+ assert pcss("x + ä") == []
+ assert pcss("*:not(ä)") == ["nil", "x", "dns-f", "dns-ä"]
def test_select_shakespeare(self) -> None:
document = html.document_fromstring(HTML_SHAKESPEARE)