From 521d7158941ae1f96b612493dcf1b33b372a7caa Mon Sep 17 00:00:00 2001 From: Adrian Chaves Date: Fri, 7 Aug 2026 14:04:50 +0200 Subject: [PATCH] Support the i and s attribute selector flags --- cssselect/parser.py | 24 ++++++++++++++++++++---- cssselect/xpath.py | 7 +++++++ docs/index.rst | 4 ++++ tests/test_cssselect.py | 35 +++++++++++++++++++++++++++++++++++ 4 files changed, 66 insertions(+), 4 deletions(-) diff --git a/cssselect/parser.py b/cssselect/parser.py index 6d06ea4..783e8d5 100644 --- a/cssselect/parser.py +++ b/cssselect/parser.py @@ -361,7 +361,10 @@ def specificity(self) -> tuple[int, int, int]: class Attrib: """ - Represents selector[namespace|attrib operator value] + Represents selector[namespace|attrib operator value flag] + + *flag* is ``'i'`` for a case-insensitive value match, ``'s'`` for a + case-sensitive one, and `None` when the selector sets neither. """ @overload @@ -372,6 +375,7 @@ def __init__( attrib: str, operator: Literal["exists"], value: None, + flag: None = None, ) -> None: ... @overload @@ -382,6 +386,7 @@ def __init__( attrib: str, operator: str, value: Token, + flag: str | None = None, ) -> None: ... def __init__( @@ -391,19 +396,22 @@ def __init__( attrib: str, operator: str, value: Token | None, + flag: str | None = None, ) -> None: self.selector = selector self.namespace = namespace self.attrib = attrib self.operator = operator self.value = value + self.flag = flag def __repr__(self) -> str: attrib = f"{self.namespace}|{self.attrib}" if self.namespace else self.attrib if self.operator == "exists": return f"{self.__class__.__name__}[{self.selector!r}[{attrib}]]" assert self.value is not None - return f"{self.__class__.__name__}[{self.selector!r}[{attrib} {self.operator} {self.value.value!r}]]" + flag = f" {self.flag}" if self.flag else "" + return f"{self.__class__.__name__}[{self.selector!r}[{attrib} {self.operator} {self.value.value!r}{flag}]]" def canonical(self) -> str: attrib = _serialize_ident(self.attrib) @@ -414,7 +422,8 @@ def canonical(self) -> str: op = attrib else: assert self.value is not None - op = f"{attrib}{self.operator}{self.value.css()}" + flag = f" {self.flag}" if self.flag else "" + op = f"{attrib}{self.operator}{self.value.css()}{flag}" return f"{self.selector.canonical()}[{op}]" @@ -850,9 +859,16 @@ def parse_attrib(selector: Tree, stream: TokenStream) -> Attrib: raise SelectorSyntaxError(f"Expected string or ident, got {value}") stream.skip_whitespace() next_ = stream.next() + flag = None + if next_.type == "IDENT": + flag = ascii_lower(cast("str", next_.value)) + if flag not in ("i", "s"): + raise SelectorSyntaxError(f"Expected ']', got {next_}") + stream.skip_whitespace() + next_ = stream.next() if next_ != ("DELIM", "]"): raise SelectorSyntaxError(f"Expected ']', got {next_}") - return Attrib(selector, namespace, cast("str", attrib), op, value) + return Attrib(selector, namespace, cast("str", attrib), op, value, flag) def parse_series(tokens: Iterable[Token]) -> tuple[int, int]: diff --git a/cssselect/xpath.py b/cssselect/xpath.py index 8ef5d7c..9cfffb0 100644 --- a/cssselect/xpath.py +++ b/cssselect/xpath.py @@ -14,6 +14,7 @@ from __future__ import annotations import re +from string import ascii_lowercase, ascii_uppercase from typing import TYPE_CHECKING, cast from cssselect.parser import ( @@ -32,6 +33,7 @@ SelectorError, SpecificityAdjustment, Tree, + ascii_lower, parse, parse_series, ) @@ -439,6 +441,11 @@ def xpath_attrib(self, selector: Attrib) -> XPathExpr: value = cast("str", selector.value.value).lower() else: value = selector.value.value + if selector.flag == "i" and value: + # ASCII-lowering both sides is what the specification defines a + # case-insensitive match as, and all XPath 1.0 can express. + attrib = f"translate({attrib}, {self.xpath_literal(ascii_uppercase)}, {self.xpath_literal(ascii_lowercase)})" + value = ascii_lower(value) return method(self.xpath(selector.selector), attrib, value) def xpath_class(self, class_selector: Class) -> XPathExpr: diff --git a/docs/index.rst b/docs/index.rst index 0f331ab..a02e801 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -118,6 +118,10 @@ be implemented): *compound selector* built only from type, class and universal selectors (e.g. ``:has(> a.important)``). Anything else is unsupported, e.g. an ID (``:has(#id)``), or a selector list (``:has(a, b)``). +* The ``i`` and ``s`` attribute selector flags, e.g. ``[href^="HTTP" i]``. + ``i`` makes the value comparison ASCII case-insensitive. ``s`` is accepted + but has no effect, since attribute values are already compared + case-sensitively. * The ``:not()`` pseudo-class with a *complex selector* argument, e.g. ``:not(a.important[rel] > b)``. Limitation: it takes a single argument, so a selector list is unsupported (e.g. ``:not(a, b)``). diff --git a/tests/test_cssselect.py b/tests/test_cssselect.py index 8e1b2a7..6e0c97c 100644 --- a/tests/test_cssselect.py +++ b/tests/test_cssselect.py @@ -137,6 +137,12 @@ def parse_many(first: str, *others: str) -> list[str]: assert parse_many("a[hreflang |= 'en']", "a[hreflang|=en]") == [ "Attrib[Element[a][hreflang |= 'en']]" ] + assert parse_many( + 'a[rel="include" i]', "a[rel=include I]", "a[rel=include\ti]" + ) == ["Attrib[Element[a][rel = 'include' i]]"] + assert parse_many('a[rel="include" s]') == [ + "Attrib[Element[a][rel = 'include' s]]" + ] assert parse_many("div:nth-child(10)") == [ "Function[Element[div]:nth-child(['10'])]" ] @@ -395,6 +401,9 @@ def css2css(css: str, res: str | None = None) -> None: css2css("[baz]") css2css('[baz="4"]', "[baz='4']") css2css('[baz^="4"]', "[baz^='4']") + css2css('[baz="4" i]', "[baz='4' i]") + css2css('[baz="4" I]', "[baz='4' i]") + css2css('[baz="4" s]', "[baz='4' s]") css2css("[ns|attr='4']") css2css("#lipsum") css2css(":not(*)") @@ -506,6 +515,12 @@ def get_error(css: str) -> str | None: "Operator expected, got " ) assert get_error("[rel=stylesheet") == ("Expected ']', got ") + assert get_error("[rel=stylesheet x]") == ( + "Expected ']', got " + ) + assert get_error("[rel=stylesheet i s]") == ( + "Expected ']', got " + ) assert get_error(":lang(fr)") is None assert get_error(":lang(fr") == ("Expected an argument, got ") assert get_error(':contains("foo') == ("Unclosed string at 10") @@ -648,6 +663,23 @@ def xpath(css: str) -> str: "e[@hreflang and (@hreflang = 'en' or starts-with(@hreflang, 'en-'))]" ) + # --- attribute case-sensitivity flags ------------------------- + lowered = ( + "translate(@foo, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', " + "'abcdefghijklmnopqrstuvwxyz')" + ) + assert xpath('e[foo="BAR" i]') == f"e[{lowered} = 'bar']" + assert xpath('e[foo*="BAR" i]') == ( + f"e[{lowered} and contains({lowered}, 'bar')]" + ) + assert xpath('e[foo$="BAR" i]') == ( + f"e[{lowered} and substring({lowered}, string-length({lowered})-2) = 'bar']" + ) + # An empty value has no case, so it is matched as-is. + assert xpath("e[foo!='' i]") == ("e[@foo != '']") + # 's' is the default. + assert xpath('e[foo="BAR" s]') == "e[@foo = 'BAR']" + # --- nth-* and nth-last-* ------------------------------------- assert xpath("e:nth-child(1)") == ("e[count(preceding-sibling::*) = 0]") @@ -1292,6 +1324,9 @@ def pcss(main: str, *selectors: str, **kwargs: bool) -> list[str]: assert pcss("a[rel]") == ["tag-anchor", "nofollow-anchor"] assert pcss('a[rel="tag"]') == ["tag-anchor"] assert pcss('a[href*="localhost"]') == ["tag-anchor"] + assert pcss('a[rel="TAG"]') == [] + assert pcss('a[rel="TAG" i]') == ["tag-anchor"] + assert pcss('a[href*="LOCALHOST" i]') == ["tag-anchor"] assert pcss('a[href*=""]') == [] assert pcss('a[href^="http"]') == ["tag-anchor", "nofollow-anchor"] assert pcss('a[href^="http:"]') == ["tag-anchor"]