uncapped 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- uncapped/__init__.py +40 -0
- uncapped/core.py +446 -0
- uncapped/dims.py +223 -0
- uncapped/py.typed +0 -0
- uncapped-0.1.0.dist-info/METADATA +254 -0
- uncapped-0.1.0.dist-info/RECORD +8 -0
- uncapped-0.1.0.dist-info/WHEEL +4 -0
- uncapped-0.1.0.dist-info/licenses/LICENSE +21 -0
uncapped/__init__.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""uncapped: get every result from searches that stop at N results.
|
|
2
|
+
|
|
3
|
+
GitHub search stops at 1,000 results, Elasticsearch at 10,000, most listing
|
|
4
|
+
sites at a few hundred pages. uncapped cuts one big search into windows that
|
|
5
|
+
each fit under the cap, by splitting the filters you give it (price, date,
|
|
6
|
+
category...), so paging through every window returns everything.
|
|
7
|
+
|
|
8
|
+
>>> from uncapped import split, DateRange
|
|
9
|
+
>>> for window in split(count, cap=1000, dims=[DateRange("created", "2008-01-01", "today")]):
|
|
10
|
+
... fetch_all(window.filters)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from .core import (
|
|
14
|
+
AsyncSplit,
|
|
15
|
+
CapExceeded,
|
|
16
|
+
Split,
|
|
17
|
+
Stats,
|
|
18
|
+
UncappedWarning,
|
|
19
|
+
Window,
|
|
20
|
+
asplit,
|
|
21
|
+
split,
|
|
22
|
+
)
|
|
23
|
+
from .dims import DateRange, Dimension, Range, Values
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"split",
|
|
27
|
+
"asplit",
|
|
28
|
+
"Range",
|
|
29
|
+
"DateRange",
|
|
30
|
+
"Values",
|
|
31
|
+
"Dimension",
|
|
32
|
+
"Window",
|
|
33
|
+
"Stats",
|
|
34
|
+
"Split",
|
|
35
|
+
"AsyncSplit",
|
|
36
|
+
"CapExceeded",
|
|
37
|
+
"UncappedWarning",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
__version__ = "0.1.0"
|
uncapped/core.py
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
"""The splitting engine."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import inspect
|
|
7
|
+
import json
|
|
8
|
+
import math
|
|
9
|
+
import warnings
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import (
|
|
12
|
+
Any,
|
|
13
|
+
AsyncIterator,
|
|
14
|
+
Awaitable,
|
|
15
|
+
Callable,
|
|
16
|
+
Dict,
|
|
17
|
+
Iterator,
|
|
18
|
+
List,
|
|
19
|
+
MutableMapping,
|
|
20
|
+
Optional,
|
|
21
|
+
Sequence,
|
|
22
|
+
Tuple,
|
|
23
|
+
Union,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
from .dims import Dimension, Slice
|
|
27
|
+
|
|
28
|
+
Filters = Dict[str, Any]
|
|
29
|
+
CountFn = Callable[[Filters], int]
|
|
30
|
+
AsyncCountFn = Callable[[Filters], Union[int, Awaitable[int]]]
|
|
31
|
+
|
|
32
|
+
_States = Dict[str, Slice]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class UncappedWarning(UserWarning):
|
|
36
|
+
"""Some results cannot be fetched. See the message and :class:`Stats`."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class CapExceeded(Exception):
|
|
40
|
+
"""Raised with ``on_capped="raise"`` when a window cannot be narrowed under the cap."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, window: "Window", cap: int) -> None:
|
|
43
|
+
super().__init__(
|
|
44
|
+
f"{window.count:,} results for {window.filters} and no dimension can narrow it "
|
|
45
|
+
f"further (cap is {cap:,}). Add another dimension or a finer step."
|
|
46
|
+
)
|
|
47
|
+
self.window = window
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class Window:
|
|
52
|
+
"""One slice of the search that returns at most ``cap`` results.
|
|
53
|
+
|
|
54
|
+
Attributes:
|
|
55
|
+
filters: Filter values for this window, keyed by dimension name, in the
|
|
56
|
+
order the dimensions were given. Dimensions that were never needed
|
|
57
|
+
are left out, so the dict is empty when the whole search fits.
|
|
58
|
+
count: What your ``count`` function returned for these filters.
|
|
59
|
+
complete: ``False`` when ``count`` is still over the cap because every
|
|
60
|
+
dimension is down to a single point. Only ``cap`` results of such a
|
|
61
|
+
window can be fetched.
|
|
62
|
+
depth: How many splits it took to get here (for a merged window, the
|
|
63
|
+
deepest of its parts).
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
filters: Filters
|
|
67
|
+
count: int
|
|
68
|
+
complete: bool = True
|
|
69
|
+
depth: int = 0
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class Stats:
|
|
74
|
+
"""What happened during a run. Read it from ``run.stats`` after iterating.
|
|
75
|
+
|
|
76
|
+
Attributes:
|
|
77
|
+
total: Count for the unfiltered search.
|
|
78
|
+
windows: Windows yielded.
|
|
79
|
+
fetchable: Results you can fetch by paging through every window
|
|
80
|
+
(``min(count, cap)`` summed over windows).
|
|
81
|
+
capped_windows: Windows still over the cap (``complete=False``).
|
|
82
|
+
unaccounted: Results the dimensions did not reach: the children of a
|
|
83
|
+
split added up to less than their parent. Usually items with no
|
|
84
|
+
value for that filter (a listing without a price) or values outside
|
|
85
|
+
the range you gave.
|
|
86
|
+
queries: Calls made to your ``count`` function.
|
|
87
|
+
cache_hits: Counts answered from the cache instead.
|
|
88
|
+
max_depth: Deepest split.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
total: int = 0
|
|
92
|
+
windows: int = 0
|
|
93
|
+
fetchable: int = 0
|
|
94
|
+
capped_windows: int = 0
|
|
95
|
+
unaccounted: int = 0
|
|
96
|
+
queries: int = 0
|
|
97
|
+
cache_hits: int = 0
|
|
98
|
+
max_depth: int = 0
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def coverage(self) -> float:
|
|
102
|
+
"""``fetchable / total``, between 0 and 1 (1.0 when there is nothing to fetch)."""
|
|
103
|
+
return 1.0 if self.total == 0 else min(1.0, self.fetchable / self.total)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass
|
|
107
|
+
class _Plan:
|
|
108
|
+
"""Settings and helpers shared by the sync and async runners."""
|
|
109
|
+
|
|
110
|
+
cap: int
|
|
111
|
+
dims: Sequence[Dimension]
|
|
112
|
+
fanout: Union[int, str]
|
|
113
|
+
max_fanout: int
|
|
114
|
+
cache: Optional[MutableMapping[str, int]]
|
|
115
|
+
on_capped: str
|
|
116
|
+
merge: bool
|
|
117
|
+
stats: Stats = field(default_factory=Stats)
|
|
118
|
+
# The newest window, held back so the next one can be merged into it:
|
|
119
|
+
# (states, window, stats.unaccounted when it was held).
|
|
120
|
+
_held: Optional[Tuple[_States, Window, int]] = field(default=None, init=False, repr=False)
|
|
121
|
+
|
|
122
|
+
def __post_init__(self) -> None:
|
|
123
|
+
if not isinstance(self.cap, int) or self.cap < 1:
|
|
124
|
+
raise ValueError(f"cap must be a positive integer, got {self.cap!r}")
|
|
125
|
+
if isinstance(self.dims, Dimension):
|
|
126
|
+
self.dims = [self.dims]
|
|
127
|
+
self.dims = list(self.dims)
|
|
128
|
+
names = [d.name for d in self.dims]
|
|
129
|
+
if len(set(names)) != len(names):
|
|
130
|
+
raise ValueError(f"dimension names must be unique, got {names}")
|
|
131
|
+
if self.fanout != "auto" and not (isinstance(self.fanout, int) and self.fanout >= 2):
|
|
132
|
+
raise ValueError(f'fanout must be "auto" or an integer >= 2, got {self.fanout!r}')
|
|
133
|
+
if self.max_fanout < 2:
|
|
134
|
+
raise ValueError(f"max_fanout must be >= 2, got {self.max_fanout!r}")
|
|
135
|
+
if self.on_capped not in ("warn", "raise", "ignore"):
|
|
136
|
+
raise ValueError(f'on_capped must be "warn", "raise" or "ignore", got {self.on_capped!r}')
|
|
137
|
+
if not isinstance(self.merge, bool):
|
|
138
|
+
raise ValueError(f"merge must be True or False, got {self.merge!r}")
|
|
139
|
+
|
|
140
|
+
# -- building filters -------------------------------------------------
|
|
141
|
+
|
|
142
|
+
def render(self, states: _States) -> Filters:
|
|
143
|
+
return {d.name: d.render(states[d.name]) for d in self.dims if d.name in states}
|
|
144
|
+
|
|
145
|
+
@staticmethod
|
|
146
|
+
def key(filters: Filters) -> str:
|
|
147
|
+
return json.dumps(filters, sort_keys=True, default=str)
|
|
148
|
+
|
|
149
|
+
def children(self, states: _States, count: int) -> Optional[List[_States]]:
|
|
150
|
+
"""Split along the first dimension that still has room, or None if none has."""
|
|
151
|
+
for dim in self.dims:
|
|
152
|
+
first, last = states.get(dim.name, dim.full)
|
|
153
|
+
if first == last:
|
|
154
|
+
continue
|
|
155
|
+
k = self._fanout(count, last - first + 1)
|
|
156
|
+
return [{**states, dim.name: part} for part in dim.parts((first, last), k)]
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
def _fanout(self, count: int, width: int) -> int:
|
|
160
|
+
if self.fanout == "auto":
|
|
161
|
+
# Aim for children at ~80% of the cap if results were spread evenly;
|
|
162
|
+
# uneven ones are simply split again.
|
|
163
|
+
k = min(self.max_fanout, math.ceil(count * 1.25 / self.cap))
|
|
164
|
+
else:
|
|
165
|
+
k = int(self.fanout)
|
|
166
|
+
return max(2, min(k, width))
|
|
167
|
+
|
|
168
|
+
# -- counting ---------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
def cached(self, filters: Filters) -> Optional[int]:
|
|
171
|
+
if self.cache is None:
|
|
172
|
+
return None
|
|
173
|
+
hit = self.cache.get(self.key(filters))
|
|
174
|
+
if hit is not None:
|
|
175
|
+
self.stats.cache_hits += 1
|
|
176
|
+
return hit
|
|
177
|
+
|
|
178
|
+
def record(self, filters: Filters, n: Any) -> int:
|
|
179
|
+
if isinstance(n, bool) or not isinstance(n, int) or n < 0:
|
|
180
|
+
raise TypeError(f"count() must return a non-negative int, got {n!r} for {filters}")
|
|
181
|
+
self.stats.queries += 1
|
|
182
|
+
if self.cache is not None:
|
|
183
|
+
self.cache[self.key(filters)] = n
|
|
184
|
+
return n
|
|
185
|
+
|
|
186
|
+
# -- results ----------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
def account(self, parent: int, children: Sequence[int]) -> None:
|
|
189
|
+
self.stats.unaccounted += max(0, parent - sum(children))
|
|
190
|
+
|
|
191
|
+
def emit(self, states: _States, n: int, depth: int) -> Window:
|
|
192
|
+
complete = n <= self.cap
|
|
193
|
+
window = Window(self.render(states), n, complete, depth)
|
|
194
|
+
if not complete:
|
|
195
|
+
if self.on_capped == "raise":
|
|
196
|
+
raise CapExceeded(window, self.cap)
|
|
197
|
+
self.stats.capped_windows += 1
|
|
198
|
+
s = self.stats
|
|
199
|
+
s.windows += 1
|
|
200
|
+
s.fetchable += min(n, self.cap)
|
|
201
|
+
s.max_depth = max(s.max_depth, depth)
|
|
202
|
+
return window
|
|
203
|
+
|
|
204
|
+
# -- merging ----------------------------------------------------------
|
|
205
|
+
#
|
|
206
|
+
# Splitting a window that sits next to a dense spot leaves many tiny
|
|
207
|
+
# neighbours (1, 3, 2... results), and each one costs at least one page
|
|
208
|
+
# request to fetch. Neighbours that together still fit under the cap are
|
|
209
|
+
# sent as one wider window instead.
|
|
210
|
+
#
|
|
211
|
+
# Two windows are merged only when they differ in nothing but their last
|
|
212
|
+
# split dimension, and the merged count is exact: windows come out in
|
|
213
|
+
# order, so everything between the two (in that dimension) was counted as
|
|
214
|
+
# empty. The one exception is results no dimension could reach (items with
|
|
215
|
+
# no value for a filter): they sit inside such a gap and a wider window
|
|
216
|
+
# would include them. If any were found since the held window, it is not
|
|
217
|
+
# merged.
|
|
218
|
+
|
|
219
|
+
def hold(self, states: _States, window: Window) -> Optional[Window]:
|
|
220
|
+
"""Take a new window; return the previous one if it can no longer grow."""
|
|
221
|
+
held = self._held
|
|
222
|
+
if held is not None:
|
|
223
|
+
merged = self._merged(held, states, window)
|
|
224
|
+
if merged is not None:
|
|
225
|
+
self._held = merged
|
|
226
|
+
self.stats.windows -= 1
|
|
227
|
+
return None
|
|
228
|
+
self._held = (states, window, self.stats.unaccounted)
|
|
229
|
+
return held[1] if held is not None else None
|
|
230
|
+
|
|
231
|
+
def release(self) -> Optional[Window]:
|
|
232
|
+
"""The last window, at the end of a run."""
|
|
233
|
+
held, self._held = self._held, None
|
|
234
|
+
return held[1] if held is not None else None
|
|
235
|
+
|
|
236
|
+
def _merged(
|
|
237
|
+
self, held: Tuple[_States, Window, int], states: _States, window: Window
|
|
238
|
+
) -> Optional[Tuple[_States, Window, int]]:
|
|
239
|
+
prev_states, prev, unaccounted = held
|
|
240
|
+
if not (self.merge and prev.complete and window.complete):
|
|
241
|
+
return None
|
|
242
|
+
if prev.count + window.count > self.cap or self.stats.unaccounted != unaccounted:
|
|
243
|
+
return None
|
|
244
|
+
if not states or prev_states.keys() != states.keys():
|
|
245
|
+
return None
|
|
246
|
+
last = next(d for d in reversed(self.dims) if d.name in states)
|
|
247
|
+
if not last.mergeable:
|
|
248
|
+
return None
|
|
249
|
+
if any(prev_states[name] != states[name] for name in states if name != last.name):
|
|
250
|
+
return None
|
|
251
|
+
span = {**states, last.name: (prev_states[last.name][0], states[last.name][1])}
|
|
252
|
+
count = prev.count + window.count
|
|
253
|
+
merged = Window(self.render(span), count, True, max(prev.depth, window.depth))
|
|
254
|
+
return span, merged, unaccounted
|
|
255
|
+
|
|
256
|
+
def finish(self) -> None:
|
|
257
|
+
s = self.stats
|
|
258
|
+
if self.on_capped == "warn" and s.capped_windows:
|
|
259
|
+
warnings.warn(
|
|
260
|
+
f"{s.capped_windows} window(s) still have more than {self.cap:,} results; only "
|
|
261
|
+
f"the first {self.cap:,} of each can be fetched. Add another dimension or use a "
|
|
262
|
+
f"finer step. Coverage: {s.coverage:.1%}.",
|
|
263
|
+
UncappedWarning,
|
|
264
|
+
stacklevel=3,
|
|
265
|
+
)
|
|
266
|
+
if s.unaccounted:
|
|
267
|
+
warnings.warn(
|
|
268
|
+
f"{s.unaccounted:,} result(s) were not reachable through the dimensions (for "
|
|
269
|
+
f"example items with no value for a filter, or values outside its range). "
|
|
270
|
+
f"Widen the ranges or fetch those items separately.",
|
|
271
|
+
UncappedWarning,
|
|
272
|
+
stacklevel=3,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class Split(_Plan):
|
|
277
|
+
"""A planned run. Iterate it to get :class:`Window` objects; read ``.stats`` after."""
|
|
278
|
+
|
|
279
|
+
def __init__(self, count: CountFn, **settings: Any) -> None:
|
|
280
|
+
super().__init__(**settings)
|
|
281
|
+
self.count = count
|
|
282
|
+
|
|
283
|
+
def _count(self, states: _States) -> int:
|
|
284
|
+
filters = self.render(states)
|
|
285
|
+
hit = self.cached(filters)
|
|
286
|
+
if hit is not None:
|
|
287
|
+
return hit
|
|
288
|
+
return self.record(filters, self.count(dict(filters)))
|
|
289
|
+
|
|
290
|
+
def __iter__(self) -> Iterator[Window]:
|
|
291
|
+
self.stats = Stats()
|
|
292
|
+
self._held = None
|
|
293
|
+
root: _States = {}
|
|
294
|
+
total = self._count(root)
|
|
295
|
+
self.stats.total = total
|
|
296
|
+
stack: List[Tuple[_States, int, int]] = [(root, total, 0)]
|
|
297
|
+
while stack:
|
|
298
|
+
states, n, depth = stack.pop()
|
|
299
|
+
if n == 0:
|
|
300
|
+
continue
|
|
301
|
+
kids = None if n <= self.cap else self.children(states, n)
|
|
302
|
+
if kids is None:
|
|
303
|
+
done = self.hold(states, self.emit(states, n, depth))
|
|
304
|
+
if done is not None:
|
|
305
|
+
yield done
|
|
306
|
+
continue
|
|
307
|
+
counts = [self._count(kid) for kid in kids]
|
|
308
|
+
self.account(n, counts)
|
|
309
|
+
stack.extend((kid, c, depth + 1) for kid, c in reversed(list(zip(kids, counts))))
|
|
310
|
+
done = self.release()
|
|
311
|
+
if done is not None:
|
|
312
|
+
yield done
|
|
313
|
+
self.finish()
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
class AsyncSplit(_Plan):
|
|
317
|
+
"""Async version of :class:`Split`. Use ``async for window in run``."""
|
|
318
|
+
|
|
319
|
+
def __init__(self, count: AsyncCountFn, *, concurrency: int = 4, **settings: Any) -> None:
|
|
320
|
+
super().__init__(**settings)
|
|
321
|
+
if concurrency < 1:
|
|
322
|
+
raise ValueError(f"concurrency must be >= 1, got {concurrency!r}")
|
|
323
|
+
self.count = count
|
|
324
|
+
self.concurrency = concurrency
|
|
325
|
+
|
|
326
|
+
async def _count(self, states: _States, gate: asyncio.Semaphore) -> int:
|
|
327
|
+
filters = self.render(states)
|
|
328
|
+
hit = self.cached(filters)
|
|
329
|
+
if hit is not None:
|
|
330
|
+
return hit
|
|
331
|
+
async with gate:
|
|
332
|
+
n = self.count(dict(filters))
|
|
333
|
+
if inspect.isawaitable(n):
|
|
334
|
+
n = await n
|
|
335
|
+
return self.record(filters, n)
|
|
336
|
+
|
|
337
|
+
def __aiter__(self) -> AsyncIterator[Window]:
|
|
338
|
+
return self._run()
|
|
339
|
+
|
|
340
|
+
async def _run(self) -> AsyncIterator[Window]:
|
|
341
|
+
self.stats = Stats()
|
|
342
|
+
self._held = None
|
|
343
|
+
gate = asyncio.Semaphore(self.concurrency)
|
|
344
|
+
root: _States = {}
|
|
345
|
+
total = await self._count(root, gate)
|
|
346
|
+
self.stats.total = total
|
|
347
|
+
stack: List[Tuple[_States, int, int]] = [(root, total, 0)]
|
|
348
|
+
while stack:
|
|
349
|
+
states, n, depth = stack.pop()
|
|
350
|
+
if n == 0:
|
|
351
|
+
continue
|
|
352
|
+
kids = None if n <= self.cap else self.children(states, n)
|
|
353
|
+
if kids is None:
|
|
354
|
+
done = self.hold(states, self.emit(states, n, depth))
|
|
355
|
+
if done is not None:
|
|
356
|
+
yield done
|
|
357
|
+
continue
|
|
358
|
+
counts = await asyncio.gather(*(self._count(kid, gate) for kid in kids))
|
|
359
|
+
self.account(n, counts)
|
|
360
|
+
stack.extend((kid, c, depth + 1) for kid, c in reversed(list(zip(kids, counts))))
|
|
361
|
+
done = self.release()
|
|
362
|
+
if done is not None:
|
|
363
|
+
yield done
|
|
364
|
+
self.finish()
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def split(
|
|
368
|
+
count: CountFn,
|
|
369
|
+
*,
|
|
370
|
+
cap: int,
|
|
371
|
+
dims: Union[Dimension, Sequence[Dimension]],
|
|
372
|
+
fanout: Union[int, str] = "auto",
|
|
373
|
+
max_fanout: int = 16,
|
|
374
|
+
cache: Optional[MutableMapping[str, int]] = None,
|
|
375
|
+
on_capped: str = "warn",
|
|
376
|
+
merge: bool = True,
|
|
377
|
+
) -> Split:
|
|
378
|
+
"""Cut a search into windows that each return at most ``cap`` results.
|
|
379
|
+
|
|
380
|
+
Args:
|
|
381
|
+
count: ``count(filters) -> int``. Return how many results the search has
|
|
382
|
+
with these filters. ``filters`` maps dimension names to values;
|
|
383
|
+
dimensions not in it should not be filtered on.
|
|
384
|
+
cap: The most results the source will return for one search.
|
|
385
|
+
dims: What to split on, in order of preference. The first dimension is
|
|
386
|
+
split until it is down to a single point, then the next one is used.
|
|
387
|
+
fanout: How many parts to cut a window into. ``"auto"`` (default) picks
|
|
388
|
+
it from how far over the cap the window is, which saves calls;
|
|
389
|
+
``2`` always halves.
|
|
390
|
+
max_fanout: Upper bound for ``"auto"``.
|
|
391
|
+
cache: Any dict-like object (``dict``, ``shelve.open(...)``) to keep
|
|
392
|
+
counts in. Pass a persistent one to resume a run without spending
|
|
393
|
+
API calls on counts you already have.
|
|
394
|
+
on_capped: What to do when a window is still over the cap and cannot be
|
|
395
|
+
narrowed: ``"warn"`` (default; it is yielded with
|
|
396
|
+
``complete=False``), ``"raise"`` :class:`CapExceeded`, or
|
|
397
|
+
``"ignore"``.
|
|
398
|
+
merge: Merge neighbouring windows that together still fit under the
|
|
399
|
+
cap (default ``True``), so fetching them takes fewer requests. Each
|
|
400
|
+
window then comes out one step later. ``False`` keeps every window
|
|
401
|
+
as it was split.
|
|
402
|
+
|
|
403
|
+
Returns:
|
|
404
|
+
A :class:`Split`. Iterate it for :class:`Window` objects in dimension
|
|
405
|
+
order; read ``.stats`` afterwards.
|
|
406
|
+
"""
|
|
407
|
+
return Split(
|
|
408
|
+
count,
|
|
409
|
+
cap=cap,
|
|
410
|
+
dims=dims,
|
|
411
|
+
fanout=fanout,
|
|
412
|
+
max_fanout=max_fanout,
|
|
413
|
+
cache=cache,
|
|
414
|
+
on_capped=on_capped,
|
|
415
|
+
merge=merge,
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def asplit(
|
|
420
|
+
count: AsyncCountFn,
|
|
421
|
+
*,
|
|
422
|
+
cap: int,
|
|
423
|
+
dims: Union[Dimension, Sequence[Dimension]],
|
|
424
|
+
fanout: Union[int, str] = "auto",
|
|
425
|
+
max_fanout: int = 16,
|
|
426
|
+
cache: Optional[MutableMapping[str, int]] = None,
|
|
427
|
+
on_capped: str = "warn",
|
|
428
|
+
merge: bool = True,
|
|
429
|
+
concurrency: int = 4,
|
|
430
|
+
) -> AsyncSplit:
|
|
431
|
+
"""Async :func:`split`. ``count`` may be async; sibling counts run concurrently.
|
|
432
|
+
|
|
433
|
+
``concurrency`` limits how many ``count`` calls run at the same time.
|
|
434
|
+
Use ``async for window in asplit(...)``.
|
|
435
|
+
"""
|
|
436
|
+
return AsyncSplit(
|
|
437
|
+
count,
|
|
438
|
+
cap=cap,
|
|
439
|
+
dims=dims,
|
|
440
|
+
fanout=fanout,
|
|
441
|
+
max_fanout=max_fanout,
|
|
442
|
+
cache=cache,
|
|
443
|
+
on_capped=on_capped,
|
|
444
|
+
merge=merge,
|
|
445
|
+
concurrency=concurrency,
|
|
446
|
+
)
|
uncapped/dims.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Dimensions: the filters a search can be narrowed by.
|
|
2
|
+
|
|
3
|
+
Every dimension is an ordered list of points (prices, dates, categories...).
|
|
4
|
+
While splitting, uncapped tracks a dimension's current slice as an inclusive
|
|
5
|
+
pair of point indices ``(first, last)`` and only turns it into real values
|
|
6
|
+
when it builds the filters it passes to your ``count`` function.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from datetime import date, datetime, timedelta
|
|
12
|
+
from decimal import Decimal
|
|
13
|
+
from typing import Any, Hashable, Iterable, List, Sequence, Tuple, Union
|
|
14
|
+
|
|
15
|
+
#: An inclusive slice of a dimension, as indices into its points.
|
|
16
|
+
Slice = Tuple[int, int]
|
|
17
|
+
|
|
18
|
+
Number = Union[int, float]
|
|
19
|
+
DateLike = Union[date, datetime, str]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Dimension:
|
|
23
|
+
"""Base class for something a search can be filtered by.
|
|
24
|
+
|
|
25
|
+
Subclasses define how many points they have (``__len__``), how a slice of
|
|
26
|
+
points is shown to your ``count`` function (:meth:`render`) and, if needed,
|
|
27
|
+
how a slice is cut into smaller slices (:meth:`parts`).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
name: str
|
|
31
|
+
|
|
32
|
+
#: Whether two neighbouring slices can be sent as one filter (a wider range).
|
|
33
|
+
#: uncapped uses this to merge small windows. ``False`` for single-value
|
|
34
|
+
#: :class:`Values`, where the source only accepts one value at a time.
|
|
35
|
+
mergeable: bool = True
|
|
36
|
+
|
|
37
|
+
def __len__(self) -> int: # pragma: no cover - abstract
|
|
38
|
+
raise NotImplementedError
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def full(self) -> Slice:
|
|
42
|
+
"""The slice covering every point of the dimension."""
|
|
43
|
+
return (0, len(self) - 1)
|
|
44
|
+
|
|
45
|
+
def render(self, part: Slice) -> Any: # pragma: no cover - abstract
|
|
46
|
+
"""The filter value your ``count`` function receives for ``part``."""
|
|
47
|
+
raise NotImplementedError
|
|
48
|
+
|
|
49
|
+
def parts(self, part: Slice, k: int) -> List[Slice]:
|
|
50
|
+
"""Cut ``part`` into ``k`` contiguous, non-overlapping slices."""
|
|
51
|
+
return _chunks(part, k)
|
|
52
|
+
|
|
53
|
+
def __repr__(self) -> str:
|
|
54
|
+
return f"{type(self).__name__}({self.name!r}, {len(self)} points)"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Range(Dimension):
|
|
58
|
+
"""A numeric range filter such as ``price_min``/``price_max``.
|
|
59
|
+
|
|
60
|
+
Your ``count`` function receives ``(lo, hi)``, both **inclusive**.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
name: Key used in the filters dict.
|
|
64
|
+
lo: Smallest value to cover.
|
|
65
|
+
hi: Largest value to cover.
|
|
66
|
+
step: The smallest difference the source can tell apart. Use ``1`` for
|
|
67
|
+
whole numbers (the default) or e.g. ``0.01`` for prices in cents.
|
|
68
|
+
|
|
69
|
+
Example::
|
|
70
|
+
|
|
71
|
+
Range("price", 0, 5_000_000) # -> filters["price"] == (0, 2_499_999)
|
|
72
|
+
Range("rating", 0, 5, step=0.1) # -> filters["rating"] == (0.0, 2.4)
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(self, name: str, lo: Number, hi: Number, step: Number = 1) -> None:
|
|
76
|
+
if step <= 0:
|
|
77
|
+
raise ValueError(f"{name}: step must be positive, got {step!r}")
|
|
78
|
+
if hi < lo:
|
|
79
|
+
raise ValueError(f"{name}: hi ({hi!r}) is smaller than lo ({lo!r})")
|
|
80
|
+
self.name = name
|
|
81
|
+
self.lo, self.hi, self.step = lo, hi, step
|
|
82
|
+
self._exact = all(isinstance(v, int) for v in (lo, hi, step))
|
|
83
|
+
if self._exact:
|
|
84
|
+
self._n = (hi - lo) // step + 1
|
|
85
|
+
else:
|
|
86
|
+
# Decimal keeps points like 0.1, 0.2, 0.3 exact instead of 0.30000000000000004.
|
|
87
|
+
self._dlo, dhi, self._dstep = (Decimal(str(v)) for v in (lo, hi, step))
|
|
88
|
+
self._n = int((dhi - self._dlo) // self._dstep) + 1
|
|
89
|
+
|
|
90
|
+
def __len__(self) -> int:
|
|
91
|
+
return self._n
|
|
92
|
+
|
|
93
|
+
def point(self, i: int) -> Number:
|
|
94
|
+
if self._exact:
|
|
95
|
+
return self.lo + i * self.step
|
|
96
|
+
return float(self._dlo + i * self._dstep)
|
|
97
|
+
|
|
98
|
+
def render(self, part: Slice) -> Tuple[Number, Number]:
|
|
99
|
+
return (self.point(part[0]), self.point(part[1]))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class DateRange(Dimension):
|
|
103
|
+
"""A date or datetime range filter such as ``created:2020-01-01..2020-06-30``.
|
|
104
|
+
|
|
105
|
+
Your ``count`` function receives ``(start, end)``, both **inclusive**, as
|
|
106
|
+
:class:`~datetime.date` or :class:`~datetime.datetime` objects (whichever
|
|
107
|
+
you passed in).
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
name: Key used in the filters dict.
|
|
111
|
+
start: First date to cover. ``date``, ``datetime`` or ISO string.
|
|
112
|
+
end: Last date to cover. ``"today"`` and ``"now"`` are accepted.
|
|
113
|
+
step: Smallest gap the source can tell apart. Defaults to one day for
|
|
114
|
+
dates and one second for datetimes.
|
|
115
|
+
|
|
116
|
+
Example::
|
|
117
|
+
|
|
118
|
+
DateRange("created", "2008-01-01", "today")
|
|
119
|
+
DateRange("posted", datetime(2024, 1, 1), "now", step=timedelta(minutes=1))
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
def __init__(
|
|
123
|
+
self,
|
|
124
|
+
name: str,
|
|
125
|
+
start: DateLike,
|
|
126
|
+
end: DateLike,
|
|
127
|
+
step: "timedelta | None" = None,
|
|
128
|
+
) -> None:
|
|
129
|
+
start, end = _as_datelike(start), _as_datelike(end)
|
|
130
|
+
if isinstance(start, datetime) != isinstance(end, datetime):
|
|
131
|
+
raise TypeError(f"{name}: start and end must both be dates or both be datetimes")
|
|
132
|
+
is_datetime = isinstance(start, datetime)
|
|
133
|
+
if step is None:
|
|
134
|
+
step = timedelta(seconds=1) if is_datetime else timedelta(days=1)
|
|
135
|
+
if step <= timedelta(0):
|
|
136
|
+
raise ValueError(f"{name}: step must be positive, got {step!r}")
|
|
137
|
+
if not is_datetime and step % timedelta(days=1):
|
|
138
|
+
raise ValueError(f"{name}: with plain dates the step must be whole days")
|
|
139
|
+
if end < start:
|
|
140
|
+
raise ValueError(f"{name}: end ({end}) is before start ({start})")
|
|
141
|
+
self.name = name
|
|
142
|
+
self.start, self.end, self.step = start, end, step
|
|
143
|
+
self._n = (end - start) // step + 1
|
|
144
|
+
|
|
145
|
+
def __len__(self) -> int:
|
|
146
|
+
return self._n
|
|
147
|
+
|
|
148
|
+
def point(self, i: int) -> "date | datetime":
|
|
149
|
+
return self.start + i * self.step
|
|
150
|
+
|
|
151
|
+
def render(self, part: Slice) -> "Tuple[date | datetime, date | datetime]":
|
|
152
|
+
return (self.point(part[0]), self.point(part[1]))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class Values(Dimension):
|
|
156
|
+
"""A categorical filter such as property type, language or country.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
name: Key used in the filters dict.
|
|
160
|
+
values: Every value to cover. Anything not listed here is not fetched
|
|
161
|
+
(uncapped reports it as ``unaccounted``).
|
|
162
|
+
multi: Set to ``True`` if the source accepts several values at once
|
|
163
|
+
(``type in (a, b, c)``). Then values are split in groups and your
|
|
164
|
+
``count`` function receives a tuple. Otherwise (the default) it is
|
|
165
|
+
tried one value at a time and receives a single value.
|
|
166
|
+
|
|
167
|
+
Example::
|
|
168
|
+
|
|
169
|
+
Values("type", ["apartment", "villa", "townhouse"])
|
|
170
|
+
Values("language", ["python", "go", "rust"], multi=True)
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
def __init__(self, name: str, values: Iterable[Hashable], *, multi: bool = False) -> None:
|
|
174
|
+
self.name = name
|
|
175
|
+
self.values: Sequence[Hashable] = tuple(values)
|
|
176
|
+
self.multi = multi
|
|
177
|
+
self.mergeable = multi
|
|
178
|
+
if not self.values:
|
|
179
|
+
raise ValueError(f"{name}: values is empty")
|
|
180
|
+
if len(set(self.values)) != len(self.values):
|
|
181
|
+
raise ValueError(f"{name}: values contains duplicates")
|
|
182
|
+
|
|
183
|
+
def __len__(self) -> int:
|
|
184
|
+
return len(self.values)
|
|
185
|
+
|
|
186
|
+
def render(self, part: Slice) -> Any:
|
|
187
|
+
first, last = part
|
|
188
|
+
if self.multi:
|
|
189
|
+
return tuple(self.values[first : last + 1])
|
|
190
|
+
if first != last:
|
|
191
|
+
raise ValueError(f"{self.name}: can only filter on one value at a time")
|
|
192
|
+
return self.values[first]
|
|
193
|
+
|
|
194
|
+
def parts(self, part: Slice, k: int) -> List[Slice]:
|
|
195
|
+
if self.multi:
|
|
196
|
+
return _chunks(part, k)
|
|
197
|
+
return [(i, i) for i in range(part[0], part[1] + 1)]
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _chunks(part: Slice, k: int) -> List[Slice]:
|
|
201
|
+
first, last = part
|
|
202
|
+
width = last - first + 1
|
|
203
|
+
k = max(1, min(k, width))
|
|
204
|
+
size, extra = divmod(width, k)
|
|
205
|
+
out = []
|
|
206
|
+
start = first
|
|
207
|
+
for i in range(k):
|
|
208
|
+
end = start + size + (1 if i < extra else 0) - 1
|
|
209
|
+
out.append((start, end))
|
|
210
|
+
start = end + 1
|
|
211
|
+
return out
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _as_datelike(value: DateLike) -> "date | datetime":
|
|
215
|
+
if isinstance(value, (date, datetime)):
|
|
216
|
+
return value
|
|
217
|
+
if value == "today":
|
|
218
|
+
return date.today()
|
|
219
|
+
if value == "now":
|
|
220
|
+
return datetime.now().replace(microsecond=0)
|
|
221
|
+
if len(value) == 10:
|
|
222
|
+
return date.fromisoformat(value)
|
|
223
|
+
return datetime.fromisoformat(value)
|
uncapped/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: uncapped
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Get every result from APIs and sites that stop at 1,000 results, by splitting the search into windows that fit under the cap.
|
|
5
|
+
Project-URL: Homepage, https://github.com/bhatti013/uncapped
|
|
6
|
+
Project-URL: Issues, https://github.com/bhatti013/uncapped/issues
|
|
7
|
+
Author: Abdul Wahab
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: api,crawler,data extraction,github search,pagination,result limit,scraping,search
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# uncapped
|
|
28
|
+
|
|
29
|
+
[](https://pypi.org/project/uncapped/)
|
|
30
|
+
[](https://pypi.org/project/uncapped/)
|
|
31
|
+
[](https://github.com/bhatti013/uncapped/actions/workflows/ci.yml)
|
|
32
|
+
[](https://github.com/bhatti013/uncapped/blob/main/LICENSE)
|
|
33
|
+
|
|
34
|
+
**Get every result from searches that stop at 1,000.**
|
|
35
|
+
|
|
36
|
+
Lots of APIs and sites cut you off no matter how many results match:
|
|
37
|
+
|
|
38
|
+
| Source | Most results per search |
|
|
39
|
+
|---|---|
|
|
40
|
+
| GitHub search API | 1,000 |
|
|
41
|
+
| Algolia (default `paginationLimitedTo`) | 1,000 |
|
|
42
|
+
| Elasticsearch / OpenSearch `from` + `size` (default `max_result_window`) | 10,000 |
|
|
43
|
+
| Google Places text search | 60 |
|
|
44
|
+
| Most listing, travel and job sites | a few hundred to ~1,000 |
|
|
45
|
+
|
|
46
|
+
The fix is always the same: narrow the search with filters (price, date,
|
|
47
|
+
category...) until each piece is under the limit, then page through every
|
|
48
|
+
piece. uncapped does the narrowing for you, picks how finely to cut so you
|
|
49
|
+
spend as few calls as possible, and tells you if anything still can't be
|
|
50
|
+
reached.
|
|
51
|
+
|
|
52
|
+
- Zero dependencies, Python 3.10+, fully typed
|
|
53
|
+
- Works with any client: `requests`, `httpx`, an SDK, Scrapy, SQL
|
|
54
|
+
- Sync and async
|
|
55
|
+
- Splits on numbers, dates and categories, and falls back to the next filter when one runs out
|
|
56
|
+
- Resumable: counts can be cached in any dict-like store
|
|
57
|
+
|
|
58
|
+
## Install
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
pip install uncapped
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Example: every Rust repo with more than 1,000 stars
|
|
65
|
+
|
|
66
|
+
GitHub's search finds 2,478 of them but will only ever return 1,000. You
|
|
67
|
+
tell uncapped how to **count** results for a set of filters; it gives you back
|
|
68
|
+
windows that each fit under the cap.
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
import requests
|
|
72
|
+
from uncapped import split, DateRange
|
|
73
|
+
|
|
74
|
+
QUERY = "language:rust stars:>1000"
|
|
75
|
+
|
|
76
|
+
def with_filters(filters):
|
|
77
|
+
if "created" not in filters:
|
|
78
|
+
return QUERY
|
|
79
|
+
lo, hi = filters["created"]
|
|
80
|
+
return f"{QUERY} created:{lo}..{hi}"
|
|
81
|
+
|
|
82
|
+
def count(filters):
|
|
83
|
+
r = requests.get("https://api.github.com/search/repositories",
|
|
84
|
+
params={"q": with_filters(filters), "per_page": 1})
|
|
85
|
+
return r.json()["total_count"]
|
|
86
|
+
|
|
87
|
+
run = split(count, cap=1000, dims=[DateRange("created", "2008-01-01", "today")])
|
|
88
|
+
|
|
89
|
+
for window in run:
|
|
90
|
+
print(window.filters["created"], window.count)
|
|
91
|
+
# page through with_filters(window.filters) as usual: it's under 1,000
|
|
92
|
+
|
|
93
|
+
print(run.stats)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Real output (November 2026):
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
created repos
|
|
100
|
+
2008-01-01 .. 2017-06-04 419
|
|
101
|
+
2017-06-05 .. 2022-02-19 984
|
|
102
|
+
2022-02-20 .. 2024-06-29 513
|
|
103
|
+
2024-06-30 .. 2026-11-06 562
|
|
104
|
+
|
|
105
|
+
2,478 repos match. Without uncapped you'd get 1,000.
|
|
106
|
+
4 windows, 2,478 fetchable (100%), 7 count requests.
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Paging through those 4 windows downloaded all 2,478 repos, with no duplicates.
|
|
110
|
+
The runnable version, with rate-limit handling and download, is
|
|
111
|
+
[`examples/github_repos.py`](https://github.com/bhatti013/uncapped/blob/main/examples/github_repos.py).
|
|
112
|
+
|
|
113
|
+
## How it works
|
|
114
|
+
|
|
115
|
+
1. Count the whole search. Under the cap? Done: one window, no filters.
|
|
116
|
+
2. Over the cap? Cut the first dimension's range into parts. With the default
|
|
117
|
+
`fanout="auto"` the number of parts depends on how far over the cap it is
|
|
118
|
+
(5× over the cap → 7 parts), so you don't pay for many rounds of halving.
|
|
119
|
+
3. Count each part. Parts under the cap become windows; parts over it are cut
|
|
120
|
+
again. Empty parts are dropped.
|
|
121
|
+
4. When a dimension is down to a single value (every listing in this window
|
|
122
|
+
costs exactly 42) uncapped moves on to the next dimension.
|
|
123
|
+
5. Neighbouring windows that together still fit under the cap are merged into
|
|
124
|
+
one wider window, so you page through fewer of them. Around a dense spot
|
|
125
|
+
(2,500 listings at one price) this turns 27 windows into 6. Merged counts
|
|
126
|
+
stay exact: uncapped never merges across results it couldn't reach. Pass
|
|
127
|
+
`merge=False` to keep every window as it was split.
|
|
128
|
+
6. Windows come out in order while the plan is still being worked out, so you
|
|
129
|
+
can start fetching before it's finished.
|
|
130
|
+
|
|
131
|
+
## Dimensions
|
|
132
|
+
|
|
133
|
+
Your `count` function receives a dict keyed by dimension name. A dimension
|
|
134
|
+
that hasn't been needed yet is **left out**, so filter on it only when it's there.
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
from datetime import datetime, timedelta
|
|
138
|
+
from uncapped import Range, DateRange, Values
|
|
139
|
+
|
|
140
|
+
Range("price", 0, 5_000_000) # filters["price"] == (0, 2_499_999), inclusive
|
|
141
|
+
Range("rating", 0, 5, step=0.1) # floats work; step is the smallest gap the source sees
|
|
142
|
+
DateRange("listed", "2023-01-01", "today") # filters["listed"] == (date(...), date(...)), inclusive
|
|
143
|
+
DateRange("posted", datetime(2024, 1, 1), "now", step=timedelta(minutes=1))
|
|
144
|
+
Values("type", ["apartment", "villa", "office"]) # one value at a time: filters["type"] == "villa"
|
|
145
|
+
Values("lang", ["python", "go", "rust"], multi=True) # groups: filters["lang"] == ("python", "go")
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Give several dimensions in order of preference. uncapped splits on the first
|
|
149
|
+
until it can't, then the second:
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
split(count, cap=1000, dims=[
|
|
153
|
+
Range("price", 0, 10_000_000),
|
|
154
|
+
DateRange("listed", "2020-01-01", "today"),
|
|
155
|
+
])
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## Knowing what you didn't get
|
|
159
|
+
|
|
160
|
+
uncapped never fails silently. After a run, `run.stats` has the numbers and an
|
|
161
|
+
`UncappedWarning` is raised for anything you'd miss:
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
Stats(total=48213, windows=61, fetchable=47650, capped_windows=1,
|
|
165
|
+
unaccounted=313, queries=74, cache_hits=0, max_depth=3)
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
- **`capped_windows`**: windows still over the cap after every dimension is
|
|
169
|
+
down to one value. They're yielded with `window.complete == False`; you can
|
|
170
|
+
fetch the first `cap` results of each. Add another dimension or a finer
|
|
171
|
+
`step`. Use `on_capped="raise"` to stop instead, or `"ignore"` to stay quiet.
|
|
172
|
+
- **`unaccounted`**: the parts of a split added up to less than the whole.
|
|
173
|
+
That's almost always items with **no value** for the filter (a listing
|
|
174
|
+
without a price never matches any price range) or values outside the range
|
|
175
|
+
you gave. Widen the range, or fetch those items another way.
|
|
176
|
+
|
|
177
|
+
## Async
|
|
178
|
+
|
|
179
|
+
`count` can be async. Sibling counts run concurrently, up to `concurrency`:
|
|
180
|
+
|
|
181
|
+
```python
|
|
182
|
+
import httpx
|
|
183
|
+
from uncapped import asplit, Range
|
|
184
|
+
|
|
185
|
+
async def count(filters):
|
|
186
|
+
lo, hi = filters.get("price", (None, None))
|
|
187
|
+
r = await client.get("/search", params={"min_price": lo, "max_price": hi, "limit": 1})
|
|
188
|
+
return r.json()["total"]
|
|
189
|
+
|
|
190
|
+
async for window in asplit(count, cap=1000, dims=[Range("price", 0, 10**7)], concurrency=8):
|
|
191
|
+
...
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## Resuming and saving calls
|
|
195
|
+
|
|
196
|
+
Pass any dict-like object as `cache` and counts are stored there. With a
|
|
197
|
+
persistent one, a crashed or repeated run re-plans without calling the API
|
|
198
|
+
again:
|
|
199
|
+
|
|
200
|
+
```python
|
|
201
|
+
import shelve
|
|
202
|
+
|
|
203
|
+
with shelve.open("counts") as cache:
|
|
204
|
+
for window in split(count, cap=1000, dims=dims, cache=cache):
|
|
205
|
+
...
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Counts are cached by their filters and don't expire. Delete the file when you
|
|
209
|
+
want fresh numbers.
|
|
210
|
+
|
|
211
|
+
## Recipes
|
|
212
|
+
|
|
213
|
+
**Elasticsearch / OpenSearch** (when you can't use `search_after` or a point in time):
|
|
214
|
+
|
|
215
|
+
```python
|
|
216
|
+
def count(filters):
|
|
217
|
+
query = {"bool": {"filter": []}}
|
|
218
|
+
if "ts" in filters:
|
|
219
|
+
lo, hi = filters["ts"]
|
|
220
|
+
query["bool"]["filter"].append({"range": {"ts": {"gte": lo.isoformat(), "lte": hi.isoformat()}}})
|
|
221
|
+
return es.count(index="events", query=query)["count"]
|
|
222
|
+
|
|
223
|
+
split(count, cap=10_000, dims=[DateRange("ts", datetime(2024, 1, 1), "now", step=timedelta(seconds=1))])
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
**A listing site's JSON API** with min/max price parameters:
|
|
227
|
+
|
|
228
|
+
```python
|
|
229
|
+
def count(filters):
|
|
230
|
+
params = {"category": "cars", "page_size": 1}
|
|
231
|
+
if "price" in filters:
|
|
232
|
+
params["price_min"], params["price_max"] = filters["price"]
|
|
233
|
+
return session.get(SEARCH_URL, params=params).json()["meta"]["total"]
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
## FAQ
|
|
237
|
+
|
|
238
|
+
**Why not just ask for page 11?** The cap is enforced by the server. Past it
|
|
239
|
+
you get an error or the same results again.
|
|
240
|
+
|
|
241
|
+
**My source's counts are estimates.** Fine: windows are still correct, but
|
|
242
|
+
`unaccounted` may show small numbers from rounding. Leave some headroom by
|
|
243
|
+
passing a slightly smaller `cap` (e.g. `950`).
|
|
244
|
+
|
|
245
|
+
**Data changes while I fetch.** Windows are planned from the counts at that
|
|
246
|
+
moment. New items in a window may push it a little over the cap; a smaller
|
|
247
|
+
`cap` gives you headroom here too.
|
|
248
|
+
|
|
249
|
+
**Is this allowed?** uncapped only decides which searches to run. Respect the
|
|
250
|
+
terms and rate limits of whatever you query.
|
|
251
|
+
|
|
252
|
+
## License
|
|
253
|
+
|
|
254
|
+
MIT
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
uncapped/__init__.py,sha256=CSbZdWhJt1LDPSpy_6HnA711tBwWRykEfWO4YWU-DAw,963
|
|
2
|
+
uncapped/core.py,sha256=SrZ__Tl9cbK08sZSTeOBbsRRURBC-6CJnXTbPII3Pfo,16854
|
|
3
|
+
uncapped/dims.py,sha256=W6EOKsWf0fCPUgIp7PN_jFNMfpHVKeDin8o8Am_rveU,8204
|
|
4
|
+
uncapped/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
uncapped-0.1.0.dist-info/METADATA,sha256=axCpZQdj_ZZh6d2Yv6-Yo3QNUMe-hPkkUBR6VeYNAmQ,9645
|
|
6
|
+
uncapped-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
7
|
+
uncapped-0.1.0.dist-info/licenses/LICENSE,sha256=J3OJcP_Nw_xrBQQoelguuzmLz5A52W4shQ1YC7uSwn0,1068
|
|
8
|
+
uncapped-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Abdul Wahab
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|