Skip to content
Merged
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
8 changes: 4 additions & 4 deletions manifest.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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",
Expand Down
109 changes: 103 additions & 6 deletions soup/soup.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# /// zerodep
# version = "0.7.0"
# version = "0.7.1"
# deps = []
# tier = "medium"
# category = "text"
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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``).
Expand All @@ -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

Expand Down Expand Up @@ -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")
Expand Down
107 changes: 107 additions & 0 deletions soup/test_soup_correctness.py
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,113 @@ def test_unsupported_attribute_syntax_raises(self, selector):
soup.select(selector)


# ── TestSelectList ──


SELECT_LIST_HTML = """<div>
<a id="t1" title="a]b">one</a>
<a id="t2" title="a,b">two</a>
<a id="t3" title="c">three</a>
</div>"""


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 ──


Expand Down
Loading