From 7a8f49d6739c687288d262cdb8fcea588dcd382e Mon Sep 17 00:00:00 2001 From: AC Date: Sat, 10 Oct 2026 15:18:02 +0800 Subject: [PATCH 1/2] Use ASCII case folding for HTML element and attribute names Signed-off-by: AC --- cssselect/xpath.py | 4 ++-- tests/test_cssselect.py | 26 ++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/cssselect/xpath.py b/cssselect/xpath.py index 57b9884..377fc28 100644 --- a/cssselect/xpath.py +++ b/cssselect/xpath.py @@ -420,7 +420,7 @@ def xpath_attrib(self, selector: Attrib) -> XPathExpr: getattr(self, f"xpath_attrib_{operator}"), ) if self.lower_case_attribute_names: - name = selector.attrib.lower() + name = ascii_lower(selector.attrib) else: name = selector.attrib if selector.namespace == "*": @@ -470,7 +470,7 @@ def xpath_element(self, selector: Element) -> XPathExpr: else: safe = bool(is_safe_name(element)) if self.lower_case_element_names: - element = element.lower() + element = ascii_lower(element) if selector.namespace == "*": # Namespace wildcard, e.g. "*|div": any namespace, # including none. XPath 1.0 has no "*:name" name test diff --git a/tests/test_cssselect.py b/tests/test_cssselect.py index d5d9ec7..1af8424 100644 --- a/tests/test_cssselect.py +++ b/tests/test_cssselect.py @@ -1361,6 +1361,32 @@ def argument_types(css: str) -> list[str]: css = f"::pseudo_element({argument_string})" assert argument_types(css) == argument_list + def test_html_non_ascii_element_names(self) -> None: + document = html.fromstring( + '
' + ) + translator = HTMLTranslator() + for selector in ("x-Ä", "X-Ä", "*|X-Ä"): + elements = typing.cast( + "list[etree._Element]", + document.xpath(translator.css_to_xpath(selector)), + ) + assert [element.get("id") for element in elements] == ["upper"] + assert len(document.xpath(translator.css_to_xpath("X-ä"))) == 1 + + def test_html_non_ascii_attribute_names(self) -> None: + document = html.fromstring( + '

' + ) + translator = HTMLTranslator() + for selector in ("[DATA-Ä]", '[DATA-Ä="yes"]', "[*|DATA-Ä]"): + elements = typing.cast( + "list[etree._Element]", + document.xpath(translator.css_to_xpath(selector)), + ) + assert [element.get("id") for element in elements] == ["upper"] + assert len(document.xpath(translator.css_to_xpath('[DATA-ä="yes"]'))) == 1 + def test_select(self) -> None: document = etree.fromstring(HTML_IDS) sort_key = {el: count for count, el in enumerate(document.iter())}.__getitem__ From fa9244eff12ec30ada5460644b1c451c4212de26 Mon Sep 17 00:00:00 2001 From: AC Date: Tue, 13 Oct 2026 19:20:25 +0800 Subject: [PATCH 2/2] Simplify HTML name regression tests to assert XPath output Signed-off-by: AC --- tests/test_cssselect.py | 41 ++++++++++++++++++++--------------------- 1 file changed, 20 insertions(+), 21 deletions(-) diff --git a/tests/test_cssselect.py b/tests/test_cssselect.py index 1af8424..4a98eee 100644 --- a/tests/test_cssselect.py +++ b/tests/test_cssselect.py @@ -1362,30 +1362,29 @@ def argument_types(css: str) -> list[str]: assert argument_types(css) == argument_list def test_html_non_ascii_element_names(self) -> None: - document = html.fromstring( - '
' - ) - translator = HTMLTranslator() - for selector in ("x-Ä", "X-Ä", "*|X-Ä"): - elements = typing.cast( - "list[etree._Element]", - document.xpath(translator.css_to_xpath(selector)), - ) - assert [element.get("id") for element in elements] == ["upper"] - assert len(document.xpath(translator.css_to_xpath("X-ä"))) == 1 + xpath = HTMLTranslator().css_to_xpath + assert xpath("x-Ä") == "descendant-or-self::*[name() = 'x-Ä']" + assert xpath("X-Ä") == "descendant-or-self::*[name() = 'x-Ä']" + assert xpath("*|X-Ä") == "descendant-or-self::*[local-name() = 'x-Ä']" + assert xpath("X-ä") == "descendant-or-self::*[name() = 'x-ä']" def test_html_non_ascii_attribute_names(self) -> None: - document = html.fromstring( - '

' + 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']" + ) + assert xpath("[*|DATA-Ä]") == ( + "descendant-or-self::*[attribute::*[local-name() = 'data-Ä']]" + ) + assert ( + xpath('[DATA-ä="yes"]') + == "descendant-or-self::*[attribute::*[name() = 'data-ä'] = 'yes']" ) - translator = HTMLTranslator() - for selector in ("[DATA-Ä]", '[DATA-Ä="yes"]', "[*|DATA-Ä]"): - elements = typing.cast( - "list[etree._Element]", - document.xpath(translator.css_to_xpath(selector)), - ) - assert [element.get("id") for element in elements] == ["upper"] - assert len(document.xpath(translator.css_to_xpath('[DATA-ä="yes"]'))) == 1 def test_select(self) -> None: document = etree.fromstring(HTML_IDS)