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 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
+ [![PyPI](https://img.shields.io/pypi/v/uncapped)](https://pypi.org/project/uncapped/)
30
+ [![Python](https://img.shields.io/pypi/pyversions/uncapped)](https://pypi.org/project/uncapped/)
31
+ [![CI](https://github.com/bhatti013/uncapped/actions/workflows/ci.yml/badge.svg)](https://github.com/bhatti013/uncapped/actions/workflows/ci.yml)
32
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.