1"""
2turbohtml.query: search a tree, the one namespace for querying nodes.
3
4Two facades over the same native CSS and XPath engines sit here. :class:`Query` is a pyquery-style fluent, chainable
5wrapper for code migrating off `pyquery <https://github.com/gawel/pyquery>`_'s jQuery-style chaining: it wraps an
6ordered, duplicate-free set of elements and every traversal and mutation method returns a :class:`Query`, so calls
7compose (``Query(html)("div").find("a").filter(".x").eq(0).attr("href")``). Alongside it, the soupsieve-shaped surface
8(:func:`compile`, :class:`Matcher`, the module-level :func:`select`/:func:`select_one`/:func:`iselect`/:func:`match`/
9:func:`filter`/:func:`closest`, :func:`escape_identifier`, and the :class:`Matching` config) mirrors soupsieve's call
10shapes for a BeautifulSoup port -- see :mod:`turbohtml.query._match` for the detail.
11
12Both are kept out of the core API (which is one-name-per-concept and not chainable) so the extra shapes stay optional.
13The :class:`Query` method names are turbohtml's own -- ``add_class`` rather than pyquery's ``addClass`` -- so a port
14adjusts the spelling but keeps the structure.
15"""
16
17from __future__ import annotations
18
19from typing import TYPE_CHECKING, cast, overload
20
21from turbohtml._html import (
22 Document,
23 Element,
24 _matches_many,
25 _query_add_class,
26 _query_attr,
27 _query_children,
28 _query_closest,
29 _query_has_class,
30 _query_parents,
31 _query_remove_class,
32 _query_siblings,
33 _query_text,
34 _query_toggle_class,
35 _query_unique,
36 _select_many,
37 parse,
38)
39
40from ._match import (
41 DEBUG,
42 Matcher,
43 Matching,
44 SelectorSyntaxError,
45 closest,
46 compile, # ruff:ignore[builtin-import-shadowing] # the soupsieve entry-point name
47 css,
48 escape_identifier,
49 filter, # ruff:ignore[builtin-import-shadowing] # the soupsieve entry-point name
50 iselect,
51 match,
52 select,
53 select_one,
54)
55
56if TYPE_CHECKING:
57 from collections.abc import Iterable, Iterator
58
59__all__ = [
60 "DEBUG",
61 "Matcher",
62 "Matching",
63 "Query",
64 "SelectorSyntaxError",
65 "closest",
66 "compile",
67 "css",
68 "escape_identifier",
69 "filter",
70 "iselect",
71 "match",
72 "select",
73 "select_one",
74]
75
76
77class Query: # ruff:ignore[too-many-public-methods] # a fluent wrapper mirrors pyquery's broad chainable method set
78 """
79 An ordered, duplicate-free set of elements with chainable traversal and mutation.
80
81 :param source: HTML to parse, a single Element or Document, or an iterable of Elements.
82 """
83
84 def __init__(self, source: str | Element | Document | Iterable[Element]) -> None:
85 """Wrap a source into a query set."""
86 if isinstance(source, str):
87 source = parse(source)
88 if isinstance(source, Document):
89 # a parsed document always has an <html> root, though the type admits None
90 self._nodes: list[Element] = [cast("Element", source.root)]
91 elif isinstance(source, Element):
92 self._nodes = [source]
93 else:
94 self._nodes = _query_unique(source)
95
96 @classmethod
97 def _wrap(cls, nodes: list[Element]) -> Query:
98 """Build a query around an already-unique, document-ordered node list, skipping the dedupe."""
99 query = cls.__new__(cls)
100 query._nodes = nodes # ruff:ignore[private-member-access] # a same-class factory setting its own private attribute
101 return query
102
103 def __call__(self, selector: str) -> Query:
104 """
105 Select the descendants of the wrapped elements matching a selector, like ``find``.
106
107 :param selector: the CSS selector.
108 :returns: a query over the matching descendants.
109 """
110 return self.find(selector)
111
112 def find(self, selector: str) -> Query:
113 """
114 Select every descendant of the wrapped elements matching a selector.
115
116 :param selector: the CSS selector.
117 :returns: a query over the matching descendants in document order.
118 """
119 if len(self._nodes) == 1:
120 # the C select() already returns unique results in document order
121 return Query._wrap(self._nodes[0].select(selector))
122 return Query._wrap(_select_many(self._nodes, selector))
123
124 def filter(self, selector: str) -> Query:
125 """
126 Keep the wrapped elements that themselves match a selector.
127
128 :param selector: the CSS selector.
129 :returns: a query over the elements that match.
130 """
131 return Query._wrap(_matches_many(self._nodes, selector))
132
133 def eq(self, index: int) -> Query:
134 """
135 Reduce the set to the element at one index.
136
137 :param index: the position in the set.
138 :returns: a query over that element, or an empty query when the index is out of range.
139 """
140 try:
141 return Query([self._nodes[index]])
142 except IndexError:
143 return Query([])
144
145 def parent(self) -> Query:
146 """
147 Select the immediate parent of each wrapped element.
148
149 :returns: a query over the parent elements.
150 """
151 return Query._wrap(_query_parents(self._nodes)) # a same-class factory
152
153 def children(self, selector: str | None = None) -> Query:
154 """
155 Select the element children of each wrapped element.
156
157 :param selector: an optional CSS selector to keep only matching children.
158 :returns: a query over the child elements.
159 """
160 result = Query._wrap(_query_children(self._nodes)) # a same-class factory
161 return result if selector is None else result.filter(selector)
162
163 def siblings(self, selector: str | None = None) -> Query:
164 """
165 Select the sibling elements of each wrapped element.
166
167 :param selector: an optional CSS selector to keep only matching siblings.
168 :returns: a query over the sibling elements.
169 """
170 result = Query._wrap(_query_siblings(self._nodes)) # a same-class factory
171 return result if selector is None else result.filter(selector)
172
173 def closest(self, selector: str) -> Query:
174 """
175 Select the nearest self-or-ancestor of each wrapped element matching a selector.
176
177 :param selector: the CSS selector.
178 :returns: a query over the nearest matching elements.
179 """
180 return Query._wrap(_query_closest(self._nodes, selector)) # a same-class factory
181
182 def items(self) -> Iterator[Query]:
183 """
184 Iterate the wrapped elements, each as its own single-element query.
185
186 :returns: an iterator of single-element queries.
187 """
188 for node in self._nodes:
189 yield Query([node])
190
191 @overload
192 def attr(self, name: str) -> str | None: ...
193 @overload
194 def attr(self, name: str, value: str) -> Query: ...
195 def attr(self, name: str, value: str | None = None) -> str | Query | None:
196 """
197 Get an attribute from the first element, or set it on every element.
198
199 :param name: the attribute name.
200 :param value: the value to set; omit to read instead.
201 :returns: the attribute value when reading, or the query when setting.
202 """
203 if value is None:
204 return _query_attr(self._nodes, name)
205 for node in self._nodes:
206 node.attrs[name] = value
207 return self
208
209 @overload
210 def text(self) -> str: ...
211 @overload
212 def text(self, value: str) -> Query: ...
213 def text(self, value: str | None = None) -> str | Query:
214 """
215 Get the combined text of every element, or set each element's text.
216
217 :param value: the text to set; omit to read instead.
218 :returns: the combined text when reading, or the query when setting.
219 """
220 if value is None:
221 return _query_text(self._nodes)
222 for node in self._nodes:
223 node.text = value
224 return self
225
226 def html(self) -> str | None:
227 """
228 Return the inner HTML of the first element.
229
230 :returns: the first element's inner HTML, or None when the set is empty.
231 """
232 return self._nodes[0].inner_html if self._nodes else None
233
234 def has_class(self, name: str) -> bool:
235 """
236 Test the wrapped elements for a class.
237
238 :param name: the class name.
239 :returns: whether any wrapped element carries the class.
240 """
241 return _query_has_class(self._nodes, name)
242
243 def add_class(self, name: str) -> Query:
244 """
245 Add a class to every wrapped element.
246
247 :param name: the class name to add.
248 :returns: the query.
249 """
250 _query_add_class(self._nodes, name)
251 return self
252
253 def remove_class(self, name: str) -> Query:
254 """
255 Remove a class from every wrapped element.
256
257 :param name: the class name to remove.
258 :returns: the query.
259 """
260 _query_remove_class(self._nodes, name)
261 return self
262
263 def toggle_class(self, name: str) -> Query:
264 """
265 Toggle a class on each element, adding it where absent and removing it where present.
266
267 :param name: the class name to toggle.
268 :returns: the query.
269 """
270 _query_toggle_class(self._nodes, name)
271 return self
272
273 def __iter__(self) -> Iterator[Element]:
274 """Iterate the wrapped elements."""
275 return iter(self._nodes)
276
277 def __len__(self) -> int:
278 """Return the number of wrapped elements."""
279 return len(self._nodes)
280
281 def __getitem__(self, index: int) -> Element:
282 """Return the wrapped element at the given index."""
283 return self._nodes[index]
284
285 def __eq__(self, other: object) -> bool:
286 """Return whether other is a query wrapping the same elements in the same order."""
287 return isinstance(other, Query) and self._nodes == other._nodes
288
289 def __hash__(self) -> int:
290 """Hash the wrapped elements by node identity."""
291 return hash(tuple(self._nodes))
292
293 def __repr__(self) -> str:
294 """Return a representation listing the wrapped elements' tags."""
295 return f"Query({[node.tag for node in self._nodes]!r})"