diff --git a/manifest.json b/manifest.json index 210e166b..45d74524 100644 --- a/manifest.json +++ b/manifest.json @@ -1,6 +1,6 @@ { "version": "1", - "generated": "2026-10-13T09:27:15.257978+00:00", + "generated": "2026-10-13T13:44:03.193247+00:00", "modules": { "a2a": { "description": "A2A (Agent-to-Agent Protocol) - Zero-dependency Python implementation", @@ -446,12 +446,12 @@ "files": [ "soup/soup.py" ], - "version": "0.7.0", + "version": "0.7.1", "deps": [], "tier": "medium", "category": "text", - "last_updated": "2026-10-13T03:17:08-05:00", - "content_hash": "f6f2e312802bc038882f60bb648af6af569c3ec5d6af6dd064e427665e5f2f89" + "last_updated": "2026-10-13T08:43:49-05:00", + "content_hash": "c6970441f91de7bdcb38470c77d1fabce4cfb04f66caa2068813cd56ab1cbf92" }, "sparse_search": { "description": "Zero-dependency sparse text search with BM25 family and TF-IDF ranking", diff --git a/soup/soup.py b/soup/soup.py index c6c1eef4..e96b5a66 100644 --- a/soup/soup.py +++ b/soup/soup.py @@ -1,5 +1,5 @@ # /// zerodep -# version = "0.7.0" +# version = "0.7.1" # deps = [] # tier = "medium" # category = "text" @@ -424,7 +424,11 @@ def select(self, css_selector: str) -> list[Tag]: Supported patterns: ``tag``, ``.class``, ``#id``, ``[attr]``, ``[attr=value]`` and the other attribute operators (``~=``, ``|=``, ``^=``, ``$=``, ``*=``), descendant (``a b``), child (``a > b``), - compound (``div.cls#id``), multiple classes (``div.a.b``). + compound (``div.cls#id``), multiple classes (``div.a.b``), and + selector lists (``a, b``). A comma separates list groups only when + it is outside a quoted value, outside brackets and outside + parentheses, so ``[title="a,b"], [title="c"]`` is the union of two + groups and not a descendant selector. Unsupported attribute syntax raises ``ValueError`` instead of dropping the constraint, which would silently widen the match. @@ -457,10 +461,18 @@ def select(self, css_selector: str) -> list[Tag]: - Rejected by the reference as well, so consistent with it: ``[=x]``, unterminated quotes such as ``[title="unterminated]``, and ``[xlink:href]``. + + A pseudo-element such as ``::before`` raises as well: it is + valid CSS the reference rejects with ``NotImplementedError``, + and dropping it used to make ``select`` return an empty list. """ - parts = _parse_selector(css_selector) + groups = _parse_selector_list(css_selector) candidates: list[Tag] = self._all_descendants() - return [tag for tag in candidates if _selector_matches(tag, parts)] + return [ + tag + for tag in candidates + if any(_selector_matches(tag, parts) for parts in groups) + ] def select_one(self, css_selector: str) -> Tag | None: """Like ``select``, but return only the first match (or ``None``). @@ -471,9 +483,9 @@ def select_one(self, css_selector: str) -> Tag | None: Returns: The first matching ``Tag``, or ``None``. """ - parts = _parse_selector(css_selector) + groups = _parse_selector_list(css_selector) for tag in self._all_descendants(): - if _selector_matches(tag, parts): + if any(_selector_matches(tag, parts) for parts in groups): return tag return None @@ -803,12 +815,97 @@ def _parse_selector(selector: str) -> list[SelectorStep]: compound, pos = _parse_compound(selector, pos) if compound is not None: tokens.append(compound) + elif selector.startswith("::", pos): + raise ValueError( + f"unsupported pseudo-element: {selector[pos:]!r} " + "(pseudo-elements are not implemented; only the pseudo-classes " + ":first-child, :last-child, :only-child and :not() are " + "supported)" + ) else: pos += 1 # skip unknown character to avoid infinite loop return _tokens_to_steps(tokens) +def _split_selector_list(selector: str) -> list[str]: + """Split a selector list on top-level commas. + + A comma separates two groups only when it is outside a quoted string, + outside square brackets and outside parentheses. A backslash escapes the + next character inside a quoted string, so ``\\"`` does not close it. + + Args: + selector: The raw selector string, e.g. ``'[title="a,b"], [title="c"]'``. + + Returns: + The group strings, in order. A selector with no top-level comma comes + back as a single-element list, so callers keep the single-selector + behaviour for free. + """ + groups: list[str] = [] + start = 0 + bracket_depth = 0 + paren_depth = 0 + quote = "" + i = 0 + n = len(selector) + while i < n: + ch = selector[i] + if quote: + if ch == "\\" and i + 1 < n: + i += 2 + continue + if ch == quote: + quote = "" + elif ch in "\"'": + quote = ch + elif ch == "[": + bracket_depth += 1 + elif ch == "]": + if bracket_depth: + bracket_depth -= 1 + elif ch == "(": + paren_depth += 1 + elif ch == ")": + if paren_depth: + paren_depth -= 1 + elif ch == "," and not bracket_depth and not paren_depth: + groups.append(selector[start:i]) + start = i + 1 + i += 1 + groups.append(selector[start:]) + return groups + + +def _parse_selector_list(selector: str) -> list[list[SelectorStep]]: + """Parse a selector list into one step list per comma-separated group. + + The caller treats the groups as a union, which is how the reference reads + a selector list: a tag matches when *any* group matches it. + + Args: + selector: The raw selector string. + + Returns: + A list with one parsed step list per group. + + Raises: + ValueError: If a non-empty selector list contains an empty group, + such as a trailing comma. An empty group parses to the match-all + selector, so it would silently widen the whole list. + """ + groups = _split_selector_list(selector) + if selector.strip(): + for group in groups: + if not group.strip(): + raise ValueError( + "empty selector group: a selector list cannot contain " + "an empty group, which would match every element" + ) + return [_parse_selector(group) for group in groups] + + def _classes_match(tag: Tag, required_classes: list[str]) -> bool: """Return True if *tag* has all *required_classes*.""" tag_classes = tag.attrs.get("class") diff --git a/soup/test_soup_correctness.py b/soup/test_soup_correctness.py index 9e62c8fb..9431dbaf 100644 --- a/soup/test_soup_correctness.py +++ b/soup/test_soup_correctness.py @@ -472,6 +472,113 @@ def test_unsupported_attribute_syntax_raises(self, selector): soup.select(selector) +# ── TestSelectList ── + + +SELECT_LIST_HTML = """
+ one + two + three +
""" + + +class TestSelectList: + """A comma in a quoted attribute value must not split the selector list. + + The comma used to be skipped outright, so a selector list silently became + a descendant selector and matched nothing at all, discarding the groups + that parse fine. The splitter now tracks quoted strings, brackets and + parentheses, and a comma only separates groups at the top level. + """ + + @staticmethod + def _ids(tags) -> list[str]: + return [tag.attrs.get("id") for tag in tags] + + @pytest.mark.parametrize( + "selector", + [ + '[title="a,b"], [title="c"]', + '[title="c"], [title="a,b"]', + '[title="a,b"][href], [title="c"]', + '[title~="a,b"], [title="c"]', + 'a, [title="c"]', + 'div > a, [title="c"]', + 'a:not([title="x,y"]), [title="c"]', + ' [title="c"] , [title="a,b"] ', + ], + ) + def test_selector_list_matches_beautifulsoup(self, selector): + ours = _ours(SELECT_LIST_HTML) + theirs = _theirs(SELECT_LIST_HTML) + assert self._ids(ours.select(selector)) == self._ids(theirs.select(selector)) + + def test_quoted_comma_does_not_split_the_list(self): + """The two list cases from the issue, in both orders.""" + soup = _ours(SELECT_LIST_HTML) + assert self._ids(soup.select('[title="a,b"], [title="c"]')) == ["t2", "t3"] + assert self._ids(soup.select('[title="c"], [title="a,b"]')) == ["t2", "t3"] + + def test_quoted_comma_inside_brackets_stays_one_group(self): + """A comma inside ``[...]`` is not a group separator.""" + soup = _ours(SELECT_LIST_HTML) + assert self._ids(soup.select('[title="a,b"][href]')) == [] + assert self._ids(soup.select('[title="a,b"][href], [title="c"]')) == ["t3"] + + def test_unbalanced_quote_is_not_swallowed(self): + """An unterminated quote must not eat the rest of the list silently. + + Without quote tracking, ``split(",")`` would cut this into pieces and + the trailing group would vanish. It now reaches the attribute parser + as one group and raises there. + """ + soup = _ours(SELECT_LIST_HTML) + with pytest.raises(ValueError): + soup.select('[title="a,b], [title="c"]') + + def test_select_one_honours_the_list(self): + soup = _ours(SELECT_LIST_HTML) + tag = soup.select_one('[title="c"], [title="a,b"]') + assert tag is not None + assert tag.attrs.get("id") == "t2" + + @pytest.mark.parametrize("selector", ['[title="c"],', 'a, , [title="c"]']) + def test_empty_group_raises(self, selector): + """A trailing or doubled comma must not widen the list to everything.""" + soup = _ours(SELECT_LIST_HTML) + with pytest.raises(ValueError, match="empty selector group"): + soup.select(selector) + + def test_empty_selector_keeps_its_match_all_behaviour(self): + # A bare empty selector is not a list, so there is no group to reject. + soup = _ours(SELECT_LIST_HTML) + assert len(soup.select("")) == 4 + + +# ── TestSelectUnsupportedPseudoElement ── + + +class TestSelectUnsupportedPseudoElement: + """A pseudo-element must raise, not silently match nothing. + + ``bogus::nonsense`` used to be dropped token by token, so ``select`` + returned ``[]`` and a selector list that contained it quietly lost that + group. The reference rejects pseudo-elements, and this parser now raises + too, consistent with the attribute-syntax contract. + """ + + @pytest.mark.parametrize("selector", ["bogus::nonsense", "a::before", "::after"]) + def test_pseudo_element_raises(self, selector): + soup = _ours(SELECT_LIST_HTML) + with pytest.raises(ValueError, match="unsupported pseudo-element"): + soup.select(selector) + + def test_pseudo_element_raises_inside_a_list(self): + soup = _ours(SELECT_LIST_HTML) + with pytest.raises(ValueError, match="unsupported pseudo-element"): + soup.select('[title="c"], bogus::nonsense') + + # ── TestSelectOne ──