From a1f7621ed1689d81d6218565e57b1e270beb12ba Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Thu, 16 Jul 2026 00:05:06 +0500 Subject: [PATCH 1/5] Fixes for canonical(), __repr__, and specificity(). --- cssselect/parser.py | 55 ++++++++++++++++++++++++++++++++--------- tests/test_cssselect.py | 43 +++++++++++++++++++++++++++++++- 2 files changed, 85 insertions(+), 13 deletions(-) diff --git a/cssselect/parser.py b/cssselect/parser.py index f969769..c73682c 100644 --- a/cssselect/parser.py +++ b/cssselect/parser.py @@ -119,8 +119,10 @@ def canonical(self) -> str: else: pseudo_element = "" res = f"{self.parsed_tree.canonical()}{pseudo_element}" - if len(res) > 1: - res = res.lstrip("*") + # Strip a redundant universal selector from e.g. "*.foo" (but not + # from e.g. "* > foo"). + if len(res) > 1 and res[0] == "*" and res[1] in "#.[:": + res = res[1:] return res def specificity(self) -> tuple[int, int, int]: @@ -272,8 +274,17 @@ def __init__(self, selector: Tree, combinator: Token, subselector: Selector): self.combinator = combinator self.subselector = subselector + def _combinator_prefix(self) -> str: + # The descendant combinator is implicit in :has() arguments. + if self.combinator.value == " ": + return "" + return f"{self.combinator.value} " + def __repr__(self) -> str: - return f"{self.__class__.__name__}[{self.selector!r}:has({self.subselector!r})]" + return ( + f"{self.__class__.__name__}[{self.selector!r}" + f":has({self._combinator_prefix()}{self.subselector!r})]" + ) def canonical(self) -> str: try: @@ -282,7 +293,7 @@ def canonical(self) -> str: subsel = self.subselector.canonical() if len(subsel) > 1: subsel = subsel.lstrip("*") - return f"{self.selector.canonical()}:has({subsel})" + return f"{self.selector.canonical()}:has({self._combinator_prefix()}{subsel})" def specificity(self) -> tuple[int, int, int]: a1, b1, c1 = self.selector.specificity() @@ -310,12 +321,16 @@ def canonical(self) -> str: selector_arguments = [] for s in self.selector_list: selarg = s.canonical() - selector_arguments.append(selarg.lstrip("*")) - args_str = ", ".join(str(s) for s in selector_arguments) + if len(selarg) > 1: + selarg = selarg.lstrip("*") + selector_arguments.append(selarg) + args_str = ", ".join(selector_arguments) return f"{self.selector.canonical()}:is({args_str})" def specificity(self) -> tuple[int, int, int]: - return max(x.specificity() for x in self.selector_list) + a1, b1, c1 = self.selector.specificity() + a2, b2, c2 = max(x.specificity() for x in self.selector_list) + return a1 + a2, b1 + b2, c1 + c2 class SpecificityAdjustment: @@ -336,12 +351,16 @@ def canonical(self) -> str: selector_arguments = [] for s in self.selector_list: selarg = s.canonical() - selector_arguments.append(selarg.lstrip("*")) - args_str = ", ".join(str(s) for s in selector_arguments) + if len(selarg) > 1: + selarg = selarg.lstrip("*") + selector_arguments.append(selarg) + args_str = ", ".join(selector_arguments) return f"{self.selector.canonical()}:where({args_str})" def specificity(self) -> tuple[int, int, int]: - return 0, 0, 0 + # :where() itself contributes no specificity, but the compound + # selector it applies to does. + return self.selector.specificity() class Attrib: @@ -474,7 +493,8 @@ def canonical(self) -> str: subsel = self.subselector.canonical() if len(subsel) > 1: subsel = subsel.lstrip("*") - return f"{self.selector.canonical()} {self.combinator} {subsel}" + combinator = " " if self.combinator == " " else f" {self.combinator} " + return f"{self.selector.canonical()}{combinator}{subsel}" def specificity(self) -> tuple[int, int, int]: a1, b1, c1 = self.selector.specificity() @@ -866,7 +886,11 @@ def value(self) -> str | None: def css(self) -> str: if self.type == "STRING": - return repr(self.value) + # Escape as CSS (repr() would use Python escapes, which mean + # something else in CSS, e.g. '\n' is just the letter 'n'). + escaped = cast("str", self.value).replace("\\", "\\\\").replace("'", "\\'") + escaped = _sub_string_control_char(_replace_string_control_char, escaped) + return f"'{escaped}'" return cast("str", self.value) @@ -912,6 +936,7 @@ def _compile(pattern: str) -> MatchFunc: _sub_simple_escape = re.compile(r"\\(.)").sub _sub_unicode_escape = re.compile(TokenMacros.unicode_escape, re.IGNORECASE).sub _sub_newline_escape = re.compile(r"\\(?:\n|\r\n|\r|\f)").sub +_sub_string_control_char = re.compile(r"[\x00-\x1f\x7f]").sub # Same as r'\1', but faster on CPython _replace_simple = operator.methodcaller("group", 1) @@ -924,6 +949,12 @@ def _replace_unicode(match: re.Match[str]) -> str: return chr(codepoint) +def _replace_string_control_char(match: re.Match[str]) -> str: + # The trailing space ends the escape sequence, in case the next + # character is a hexadecimal digit. + return f"\\{ord(match.group()):x} " + + def unescape_ident(value: str) -> str: value = _sub_unicode_escape(_replace_unicode, value) return _sub_simple_escape(_replace_simple, value) diff --git a/tests/test_cssselect.py b/tests/test_cssselect.py index dc67bb7..3f4b753 100644 --- a/tests/test_cssselect.py +++ b/tests/test_cssselect.py @@ -160,6 +160,15 @@ def parse_many(first: str, *others: str) -> list[str]: assert parse_many("div:has(div.foo)") == [ "Relation[Element[div]:has(Selector[Class[Element[div].foo]])]" ] + assert parse_many("div:has(> div.foo)") == [ + "Relation[Element[div]:has(> Selector[Class[Element[div].foo]])]" + ] + assert parse_many("div:has(+ div.foo)") == [ + "Relation[Element[div]:has(+ Selector[Class[Element[div].foo]])]" + ] + assert parse_many("div:has(~ div.foo)") == [ + "Relation[Element[div]:has(~ Selector[Class[Element[div].foo]])]" + ] assert parse_many("div:is(.foo, #bar)") == [ "Matching[Element[div]:is(Class[Element[*].foo], Hash[Element[*]#bar])]" ] @@ -311,6 +320,12 @@ def specificity(css: str) -> tuple[int, int, int]: assert specificity(":is(.foo, #bar)") == (1, 0, 0) assert specificity(":is(:hover, :visited)") == (0, 1, 0) assert specificity(":where(:hover, :visited)") == (0, 0, 0) + # The compound selector the pseudo-class applies to counts too + assert specificity("div:is(#a)") == (1, 0, 1) + assert specificity("div:is(.f, .g)") == (0, 1, 1) + assert specificity("div.e:is(.f)") == (0, 2, 1) + assert specificity("div:where(.x)") == (0, 0, 1) + assert specificity("div.e:where(.x)") == (0, 1, 1) assert specificity("foo:empty") == (0, 1, 1) assert specificity("foo:before") == (0, 0, 2) @@ -327,7 +342,12 @@ def test_css_export(self) -> None: def css2css(css: str, res: str | None = None) -> None: selectors = parse(css) assert len(selectors) == 1 - assert selectors[0].canonical() == (res or css) + canonical = selectors[0].canonical() + assert canonical == (res or css) + # canonical() output must round-trip through the parser + reparsed = parse(canonical) + assert len(reparsed) == 1 + assert reparsed[0].canonical() == canonical css2css("*") css2css(" foo", "foo") @@ -352,15 +372,36 @@ def css2css(css: str, res: str | None = None) -> None: css2css(":has(*)") css2css(":has(foo)") css2css(":has(*.foo)", ":has(.foo)") + # combinators inside :has() are kept + css2css(":has(> foo)") + css2css(":has(~ foo)") + css2css(":has(+ foo)") + css2css("div:has(> div.foo)") css2css(":is(#bar, .foo)") css2css(":is(:focused, :visited)") css2css(":where(:focused, :visited)") + # a universal selector argument is kept + css2css(":is(*)") + css2css("div:is(*)") + css2css(":is(*, .foo)") + css2css(":where(*)") css2css("foo:empty") css2css("foo::before") css2css("foo:empty::before") css2css('::name(arg + "val" - 3)', "::name(arg+'val'-3)") css2css("#lorem + foo#ipsum:first-child > bar::first-line") css2css("foo > *") + # a leading universal selector is only redundant in a compound + # selector + css2css("* > foo") + css2css("* foo") + # a single space for the descendant combinator + css2css("div p") + css2css("div \t\n p", "div p") + # strings are escaped as CSS, not as Python literals + css2css(r'[foo="x\a y"]', r"[foo='x\a y']") + css2css(r'[foo="\\"]', r"[foo='\\']") + css2css('[foo="\'"]', "[foo='\\'']") def test_parse_errors(self) -> None: def get_error(css: str) -> str | None: From 3fb5664f190020102c6175078d95302b0e26ed1d Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Thu, 16 Jul 2026 01:13:21 +0500 Subject: [PATCH 2/5] Escape identifiers in canonical(). --- cssselect/parser.py | 44 ++++++++++++++++++++++++++++++++++++----- tests/test_cssselect.py | 11 +++++++++++ 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/cssselect/parser.py b/cssselect/parser.py index c73682c..02853d6 100644 --- a/cssselect/parser.py +++ b/cssselect/parser.py @@ -150,7 +150,7 @@ def __repr__(self) -> str: return f"{self.__class__.__name__}[{self.selector!r}.{self.class_name}]" def canonical(self) -> str: - return f"{self.selector.canonical()}.{self.class_name}" + return f"{self.selector.canonical()}.{_serialize_ident(self.class_name)}" def specificity(self) -> tuple[int, int, int]: a, b, c = self.selector.specificity() @@ -410,7 +410,9 @@ def __repr__(self) -> str: return f"{self.__class__.__name__}[{self.selector!r}[{attrib} {self.operator} {self.value.value!r}]]" def canonical(self) -> str: - attrib = f"{self.namespace}|{self.attrib}" if self.namespace else self.attrib + attrib = _serialize_ident(self.attrib) + if self.namespace: + attrib = f"{_serialize_ident(self.namespace)}|{attrib}" if self.operator == "exists": op = attrib @@ -444,9 +446,9 @@ def __repr__(self) -> str: return f"{self.__class__.__name__}[{self.canonical()}]" def canonical(self) -> str: - element = self.element or "*" + element = _serialize_ident(self.element) if self.element else "*" if self.namespace: - element = f"{self.namespace}|{element}" + element = f"{_serialize_ident(self.namespace)}|{element}" return element def specificity(self) -> tuple[int, int, int]: @@ -468,7 +470,7 @@ def __repr__(self) -> str: return f"{self.__class__.__name__}[{self.selector!r}#{self.id}]" def canonical(self) -> str: - return f"{self.selector.canonical()}#{self.id}" + return f"{self.selector.canonical()}#{_serialize_ident(self.id)}" def specificity(self) -> tuple[int, int, int]: a, b, c = self.selector.specificity() @@ -891,6 +893,8 @@ def css(self) -> str: escaped = cast("str", self.value).replace("\\", "\\\\").replace("'", "\\'") escaped = _sub_string_control_char(_replace_string_control_char, escaped) return f"'{escaped}'" + if self.type == "IDENT": + return _serialize_ident(cast("str", self.value)) return cast("str", self.value) @@ -960,6 +964,36 @@ def unescape_ident(value: str) -> str: return _sub_simple_escape(_replace_simple, value) +def _serialize_ident(value: str) -> str: + """Serialize a string as a CSS identifier, escaping special characters. + + Implements the CSSOM "serialize an identifier" algorithm: + https://drafts.csswg.org/cssom/#serialize-an-identifier + """ + result = [] + for i, char in enumerate(value): + code = ord(char) + serialized = char + if code == 0: + serialized = "\N{REPLACEMENT CHARACTER}" + elif code <= 0x1F or code == 0x7F: + serialized = f"\\{code:x} " + elif "0" <= char <= "9": + if i == 0 or (i == 1 and value[0] == "-"): + # An identifier cannot start with a digit + # (or a '-' followed by a digit). + serialized = f"\\{code:x} " + elif char == "-": + if len(value) == 1: + serialized = "\\-" + elif not ( + code >= 0x80 or char == "_" or "a" <= char <= "z" or "A" <= char <= "Z" + ): + serialized = f"\\{char}" + result.append(serialized) + return "".join(result) + + def tokenize(s: str) -> Iterator[Token]: pos = 0 len_s = len(s) diff --git a/tests/test_cssselect.py b/tests/test_cssselect.py index 3f4b753..df2dfb0 100644 --- a/tests/test_cssselect.py +++ b/tests/test_cssselect.py @@ -402,6 +402,17 @@ def css2css(css: str, res: str | None = None) -> None: css2css(r'[foo="x\a y"]', r"[foo='x\a y']") css2css(r'[foo="\\"]', r"[foo='\\']") css2css('[foo="\'"]', "[foo='\\'']") + # identifiers are escaped as CSS on output + css2css(r"di\[v") + css2css(r"e.cl\@ss") + css2css(r".fo\.o") + css2css(r"#a\.b") + css2css(r"[h\]ref]") + css2css(r"[foo=ba\.r]") + css2css(r"n\.s|div") + css2css(r"\31 23") # an identifier cannot start with a bare digit + css2css(r"e\1 x") # control characters use hexadecimal escapes + css2css(r"di\5b v", r"di\[v") # hexadecimal escapes are canonicalized def test_parse_errors(self) -> None: def get_error(css: str) -> str | None: From e550f4ff065ff29512016185aedcbc3da5454a78 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Thu, 16 Jul 2026 23:10:51 +0500 Subject: [PATCH 3/5] Complete coverage for _serialize_ident(). --- tests/test_cssselect.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_cssselect.py b/tests/test_cssselect.py index df2dfb0..b4897fc 100644 --- a/tests/test_cssselect.py +++ b/tests/test_cssselect.py @@ -413,6 +413,8 @@ def css2css(css: str, res: str | None = None) -> None: css2css(r"\31 23") # an identifier cannot start with a bare digit css2css(r"e\1 x") # control characters use hexadecimal escapes css2css(r"di\5b v", r"di\[v") # hexadecimal escapes are canonicalized + css2css(r".\-") # an identifier consisting of a single "-" + css2css(r"e\0 x", "e\N{REPLACEMENT CHARACTER}x") # NUL becomes U+FFFD def test_parse_errors(self) -> None: def get_error(css: str) -> str | None: From 2de48dcca1464b5196635dfc92ff11483df8de53 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Thu, 16 Jul 2026 23:21:03 +0500 Subject: [PATCH 4/5] Cover another branch. --- tests/test_cssselect.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_cssselect.py b/tests/test_cssselect.py index b4897fc..cd46bf9 100644 --- a/tests/test_cssselect.py +++ b/tests/test_cssselect.py @@ -413,7 +413,8 @@ def css2css(css: str, res: str | None = None) -> None: css2css(r"\31 23") # an identifier cannot start with a bare digit css2css(r"e\1 x") # control characters use hexadecimal escapes css2css(r"di\5b v", r"di\[v") # hexadecimal escapes are canonicalized - css2css(r".\-") # an identifier consisting of a single "-" + css2css(r".\-") # an identifier consisting of a single "-" is escaped + css2css(r".-x") # an identifier just starting with "-" isn't escaped css2css(r"e\0 x", "e\N{REPLACEMENT CHARACTER}x") # NUL becomes U+FFFD def test_parse_errors(self) -> None: From 68b38e95e4931109d192b26e28c9fa913670ffeb Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Fri, 17 Jul 2026 12:29:56 +0500 Subject: [PATCH 5/5] More escaping, remove dead code. --- cssselect/parser.py | 24 +++++++++++------------- tests/test_cssselect.py | 11 +++++++++++ 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/cssselect/parser.py b/cssselect/parser.py index 02853d6..ae34341 100644 --- a/cssselect/parser.py +++ b/cssselect/parser.py @@ -115,7 +115,7 @@ def canonical(self) -> str: if isinstance(self.pseudo_element, FunctionalPseudoElement): pseudo_element = f"::{self.pseudo_element.canonical()}" elif self.pseudo_element: - pseudo_element = f"::{self.pseudo_element}" + pseudo_element = f"::{_serialize_ident(self.pseudo_element)}" else: pseudo_element = "" res = f"{self.parsed_tree.canonical()}{pseudo_element}" @@ -189,7 +189,7 @@ def argument_types(self) -> list[str]: def canonical(self) -> str: args = "".join(token.css() for token in self.arguments) - return f"{self.name}({args})" + return f"{_serialize_ident(self.name)}({args})" class Function: @@ -211,7 +211,7 @@ def argument_types(self) -> list[str]: def canonical(self) -> str: args = "".join(token.css() for token in self.arguments) - return f"{self.selector.canonical()}:{self.name}({args})" + return f"{self.selector.canonical()}:{_serialize_ident(self.name)}({args})" def specificity(self) -> tuple[int, int, int]: a, b, c = self.selector.specificity() @@ -232,7 +232,7 @@ def __repr__(self) -> str: return f"{self.__class__.__name__}[{self.selector!r}:{self.ident}]" def canonical(self) -> str: - return f"{self.selector.canonical()}:{self.ident}" + return f"{self.selector.canonical()}:{_serialize_ident(self.ident)}" def specificity(self) -> tuple[int, int, int]: a, b, c = self.selector.specificity() @@ -287,20 +287,14 @@ def __repr__(self) -> str: ) def canonical(self) -> str: - try: - subsel = self.subselector[0].canonical() # type: ignore[index] - except TypeError: - subsel = self.subselector.canonical() + subsel = self.subselector.canonical() if len(subsel) > 1: subsel = subsel.lstrip("*") return f"{self.selector.canonical()}:has({self._combinator_prefix()}{subsel})" def specificity(self) -> tuple[int, int, int]: a1, b1, c1 = self.selector.specificity() - try: - a2, b2, c2 = self.subselector[-1].specificity() # type: ignore[index] - except TypeError: - a2, b2, c2 = self.subselector.specificity() + a2, b2, c2 = self.subselector.specificity() return a1 + a2, b1 + b2, c1 + c2 @@ -984,7 +978,11 @@ def _serialize_ident(value: str) -> str: # (or a '-' followed by a digit). serialized = f"\\{code:x} " elif char == "-": - if len(value) == 1: + if len(value) == 1 or (i == 0 and value[1] == "-"): + # CSSOM leaves a leading "--" unescaped (such identifiers + # are valid since CSS Syntax 3), but the tokenizer only + # implements the CSS 2.1 identifier grammar and would not + # be able to parse the result, so escape the first "-". serialized = "\\-" elif not ( code >= 0x80 or char == "_" or "a" <= char <= "z" or "A" <= char <= "Z" diff --git a/tests/test_cssselect.py b/tests/test_cssselect.py index cd46bf9..8590ed4 100644 --- a/tests/test_cssselect.py +++ b/tests/test_cssselect.py @@ -416,6 +416,17 @@ def css2css(css: str, res: str | None = None) -> None: css2css(r".\-") # an identifier consisting of a single "-" is escaped css2css(r".-x") # an identifier just starting with "-" isn't escaped css2css(r"e\0 x", "e\N{REPLACEMENT CHARACTER}x") # NUL becomes U+FFFD + # a leading "--" is escaped: the tokenizer cannot parse it unescaped + css2css(r".\--x") + css2css(r".\--") + css2css(r"e\--x", "e--x") # but a non-leading "--" needs no escape + # pseudo-class, functional pseudo-class and pseudo-element names + # are escaped too + css2css(r":fo\.o") + css2css(r":\31 23") + css2css(r":fo\.o(2)") + css2css(r"::fo\.o") + css2css(r"::fo\.o(2)") def test_parse_errors(self) -> None: def get_error(css: str) -> str | None: