Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 20 additions & 4 deletions cssselect/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -372,6 +375,7 @@ def __init__(
attrib: str,
operator: Literal["exists"],
value: None,
flag: None = None,
) -> None: ...

@overload
Expand All @@ -382,6 +386,7 @@ def __init__(
attrib: str,
operator: str,
value: Token,
flag: str | None = None,
) -> None: ...

def __init__(
Expand All @@ -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)
Expand All @@ -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}]"

Expand Down Expand Up @@ -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]:
Expand Down
7 changes: 7 additions & 0 deletions cssselect/xpath.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -32,6 +33,7 @@
SelectorError,
SpecificityAdjustment,
Tree,
ascii_lower,
parse,
parse_series,
)
Expand Down Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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)``).
Expand Down
35 changes: 35 additions & 0 deletions tests/test_cssselect.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'])]"
]
Expand Down Expand Up @@ -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(*)")
Expand Down Expand Up @@ -506,6 +515,12 @@ def get_error(css: str) -> str | None:
"Operator expected, got <DELIM ':' at 4>"
)
assert get_error("[rel=stylesheet") == ("Expected ']', got <EOF at 15>")
assert get_error("[rel=stylesheet x]") == (
"Expected ']', got <IDENT 'x' at 16>"
)
assert get_error("[rel=stylesheet i s]") == (
"Expected ']', got <IDENT 's' at 18>"
)
assert get_error(":lang(fr)") is None
assert get_error(":lang(fr") == ("Expected an argument, got <EOF at 8>")
assert get_error(':contains("foo') == ("Unclosed string at 10")
Expand Down Expand Up @@ -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]")

Expand Down Expand Up @@ -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"]
Expand Down
Loading