From 18de55033f1b75ffeb881b2a6606f907e53a139c Mon Sep 17 00:00:00 2001 From: jwalith4418-Mj <232281058+jwalith4418-Mj@users.noreply.github.com> Date: Fri, 6 Nov 2026 19:45:03 +0530 Subject: [PATCH] Use ASCII case folding for attribute selectors Apply the existing ascii_lower helper to both operands of i-flag comparisons, as required by Selectors Level 4. Cover all six operators, HTML/XML, non-ASCII-to-ASCII folds and explicit/default case controls. AI-assisted investigation, implementation and validation using OpenAI Codex. --- cssselect2/compiler.py | 4 ++-- tests/test_cssselect2.py | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/cssselect2/compiler.py b/cssselect2/compiler.py index ecfce97..94493a3 100644 --- a/cssselect2/compiler.py +++ b/cssselect2/compiler.py @@ -207,8 +207,8 @@ def _compile_node(selector): value = selector.value attribute_value = f'el.etree_element.get({key}, "")' if selector.case_sensitive is False: - value = value.lower() - attribute_value += '.lower()' + value = ascii_lower(value) + attribute_value = f'ascii_lower({attribute_value})' if selector.operator is None: return f'{key} in el.etree_element.attrib' elif selector.operator == '=': diff --git a/tests/test_cssselect2.py b/tests/test_cssselect2.py index 3f5a917..36e0d31 100644 --- a/tests/test_cssselect2.py +++ b/tests/test_cssselect2.py @@ -346,6 +346,45 @@ def test_html_select(selector, result): ElementWrapper.from_html_root(IDS_ROOT).query_all(selector)] +@pytest.mark.parametrize('wrapper', [ + ElementWrapper.from_xml_root, ElementWrapper.from_html_root]) +@pytest.mark.parametrize(('operator', 'value'), [ + ('=', 'Éa'), ('~=', 'before Éa after'), ('|=', 'Éa-fr'), + ('^=', 'Éa-after'), ('$=', 'before-Éa'), ('*=', 'before-Éa-after'), +]) +def test_attribute_case_insensitive_ascii_only(wrapper, operator, value): + document = etree.Element('div') + etree.SubElement(document, 'div', {'id': 'upper', 'data-value': value}) + etree.SubElement(document, 'div', { + 'id': 'lower', 'data-value': value.replace('É', 'é')}) + root = wrapper(document) + assert [element.id for element in root.query_all( + f'[data-value{operator}"ÉA" i]')] == ['upper'] + assert [element.id for element in root.query_all( + f'[data-value{operator}"éA" i]')] == ['lower'] + + +@pytest.mark.parametrize('wrapper', [ + ElementWrapper.from_xml_root, ElementWrapper.from_html_root]) +@pytest.mark.parametrize(('operator', 'value', 'selector_value'), [ + ('=', 'K', 'k'), ('=', 'k', 'K'), ('*=', 'İ', 'i'), +]) +def test_attribute_case_insensitive_non_ascii_to_ascii( + wrapper, operator, value, selector_value): + root = wrapper(etree.Element('div', {'data-value': value})) + assert not root.matches(f'[data-value{operator}"{selector_value}" i]') + + +@pytest.mark.parametrize('wrapper', [ + ElementWrapper.from_xml_root, ElementWrapper.from_html_root]) +@pytest.mark.parametrize(('flag', 'matches'), [ + ('', False), ('s', False), ('S', False), ('i', True), ('I', True), +]) +def test_attribute_case_flags_ascii_controls(wrapper, flag, matches): + root = wrapper(etree.Element('div', {'data-value': 'AbC'})) + assert root.matches(f'[data-value="abc" {flag}]') is matches + + # Data borrowed from http://mootools.net/slickspeed/ @pytest.mark.parametrize(('selector', 'result'), [ # Changed from original because we’re only searching the body.