From soupsieve¶
soupsieve is BeautifulSoup’s CSS selector engine and the library behind
every Tag.select call. soupsieve.compile(selector) returns a reusable SoupSieve matcher, and the
module-level select / select_one / iselect / match / filter / closest helpers run one-shot
queries over bs4 tags. Its scope is deliberately narrow: it interprets a CSS selector against an already-parsed tree of
BeautifulSoup Tag objects, one element at a time, in Python. It covers a broad slice of Selectors Level 3 and 4 and
adds a few proprietary extensions (:-soup-contains(), [attr!=value], custom pseudo-class aliases). Because it is
the default engine that bs4 reaches for, it ships with nearly every project that scrapes or queries HTML in Python.
turbohtml.query covers that same ground with the same call shapes over turbohtml’s native selector engine. A port
swaps import soupsieve for from turbohtml import query and keeps its structure: the matcher methods and module
helpers keep soupsieve’s names, and turbohtml parses the HTML itself rather than depending on a separate BeautifulSoup
tree.
turbohtml vs soupsieve¶
Dimension |
turbohtml |
soupsieve |
|---|---|---|
Scope |
Full WHATWG HTML parser with a native CSS selector engine, an XPath engine, and serialization in one package. |
CSS selector engine only; relies on BeautifulSoup to parse and hold the tree it queries. |
Feature breadth |
Selectors Level 4 subset over the native tree, plus |
Broad Selectors 3/4 support plus proprietary |
Performance |
Selector compiled against the tree once, matched by comparing interned integer atoms in C. |
Each selector interpreted in Python per element on every call. |
Typing |
Ships |
Ships |
Dependencies |
One self-contained package with a bundled C extension; the parser is built in. |
No hard runtime dependency, but it operates on BeautifulSoup trees, so bs4 is the practical runtime. |
Maintenance |
Actively maintained. |
Actively maintained; the canonical selector engine shipped with BeautifulSoup. |
Feature overlap¶
These port 1:1 – same names, turbohtml nodes in place of bs4 tags:
compile()returning a reusableMatcher, the analog ofSoupSieve.The module helpers
select(),select_one(),iselect(),match(),filter(), andclosest().The same six methods on
Matcher, plus itspattern/namespaces/flagsproperties.limit=onselect/iselectto cap the number of matches.turbohtml.query.escape_identifier()for building a selector around untrusted class or id text.turbohtml.SelectorSyntaxError(aValueError) raised on a malformed selector.
What turbohtml adds¶
A compiled matcher runs in C against interned atoms instead of interpreting the selector in Python per element (see Performance).
Parsing is built in:
turbohtml.parse()produces the tree the selector runs over, so there is no separate BeautifulSoup dependency to install and keep in sync.css()is a readable alias ofcompile()for call sites that read ascss(selector).Beyond CSS, the same tree answers
find_all()keyword filters (attrs=,class_=,text=) andxpath()queries.Spec-conformant matching where soupsieve 2.8 diverges: an only child matches a functional
:nth-child(An+B), andinput[type=hidden]counts as:enabled(see Gotchas).No global compile cache to manage, so
soupsieve.purge()has no analog and needs none – aMatcheris the reusable artifact you hold yourself.
What soupsieve has that turbohtml does not¶
:-soup-contains()/:-soup-contains-own()text selectors. No CSS equivalent; query text throughfind_all()withtext=instead.The proprietary
[attr!=value]selector. Write:not([attr=value]).custom={":--name": "..."}pseudo-class aliases. No equivalent; inline the aliased selector, with:is(...)covering most uses.Namespace discrimination: soupsieve honors the
namespacesmap for prefixed type selectors. turbohtml selects by local name, sosvg|rectmatches everyrectregardless of prefix;namespacesis carried for parity but inert.Three tracked selector gaps where the engines still disagree on real documents: #349, #350, #351.
Performance¶
soupsieve interprets each selector in Python per element; turbohtml compiles it against the tree once and matches by
comparing interned integer atoms in C, so a compiled select runs 195 to 1,126 times faster across real pages and
per-element match 94 to 155 times faster:
operation |
turbohtml |
|
|---|---|---|
select div a[href] — daring fireball (10 kB) |
639 ns |
139 µs (217x) |
select div a[href] — ars technica (56 kB) |
1.46 µs |
501 µs (343x) |
select div a[href] — mozilla blog (95 kB) |
2.07 µs |
872 µs (422x) |
select div a[href] — whatwg spec (235 kB) |
1.74 µs |
2.28 ms (1313x) |
select div:has(a) — daring fireball (10 kB) |
256 ns |
92.9 µs (363x) |
select div:has(a) — ars technica (56 kB) |
1.28 µs |
415 µs (325x) |
select div:has(a) — mozilla blog (95 kB) |
8.97 µs |
1.78 ms (199x) |
select div:has(a) — whatwg spec (235 kB) |
6.08 µs |
2.58 ms (425x) |
match each anchor against div a[href] — daring fireball (10 kB) |
1.89 µs |
167 µs (88.1x) |
match each anchor against div a[href] — ars technica (56 kB) |
4.28 µs |
782 µs (183x) |
match each anchor against div a[href] — mozilla blog (95 kB) |
6.05 µs |
1.08 ms (179x) |
match each anchor against div a[href] — whatwg spec (235 kB) |
7.3 µs |
1.25 ms (171x) |
The engines agree on what they select: on a corpus of 247 selectors harvested from soupsieve’s own test suite, both pick identical elements for every comparable selector over real documents, and the disagreements are the three tracked turbohtml gaps above plus two spots where soupsieve itself strays from the spec (see Gotchas).
How to migrate¶
Swap the import and parse with turbohtml; the matcher and helper names carry over:
# soupsieve
import soupsieve as sv
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, "html.parser")
matcher = sv.compile("li.on a[href]")
matcher.select(soup, limit=5)
# turbohtml
from turbohtml import parse, query
doc = parse(html)
matcher = query.compile("li.on a[href]")
matcher.select(doc, limit=5)
Only the trees and the keyword bundle differ; every call name stays.
soupsieve |
turbohtml |
|---|---|
|
|
|
the same names taking turbohtml nodes: |
|
the same methods on |
|
the same properties on |
|
|
|
|
|
not needed; there is no global cache, a |
|
not supported; inline the aliased selector ( |
A full round-trip, from parse to selection to matching:
from turbohtml import parse, query
doc = parse('<ul><li class="on"><a href="/a">a</a></li><li><a href="/b">b</a></li></ul>')
print([a.attr("href") for a in query.select("li.on a[href]", doc)])
print(query.match("li.on", query.compile("li").closest(query.select_one("a", doc))))
['/a']
True
Gotchas and pitfalls¶
The nodes are turbohtml’s, so parse with
turbohtml.parse()and pass theDocumentor anElementwhere soupsieve took a bs4Tag; matches come back as turbohtml elements withattr()/textinstead oftag["attr"]/tag.get_text().soupsieve’s proprietary selectors raise
SelectorSyntaxError:[attr!=value](write:not([attr=value])) and:-soup-contains()/:-soup-contains-own()(query text throughfind_all()withtext=instead).namespacesandflagsare carried for parity but never change which elements match: turbohtml selects by local name, sosvg|rectmatches everyrectwhatever the mapping says, andDEBUGprints nothing.Two soupsieve 2.8 behaviors are off spec and not reproduced: an only child never matches a functional
:nth-child(An+B)in soupsieve (an only child sits at position 1, so:nth-child(-n+3)must match it here), andinput[type=hidden]is excluded from:enabledin soupsieve (the current HTML spec no longer carves out hidden inputs).Pseudo-classes soupsieve accepts and matches never (
:current,:paused,:local-link,:host, the removed:matches(), …) raise here instead, turning a selector that silently returned nothing into a loud error.