uncapped 0.1.0__tar.gz
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-0.1.0/.gitignore +10 -0
- uncapped-0.1.0/CHANGELOG.md +13 -0
- uncapped-0.1.0/LICENSE +21 -0
- uncapped-0.1.0/PKG-INFO +254 -0
- uncapped-0.1.0/README.md +228 -0
- uncapped-0.1.0/examples/github_repos.py +99 -0
- uncapped-0.1.0/pyproject.toml +44 -0
- uncapped-0.1.0/src/uncapped/__init__.py +40 -0
- uncapped-0.1.0/src/uncapped/core.py +446 -0
- uncapped-0.1.0/src/uncapped/dims.py +223 -0
- uncapped-0.1.0/src/uncapped/py.typed +0 -0
- uncapped-0.1.0/tests/fakes.py +79 -0
- uncapped-0.1.0/tests/test_async.py +62 -0
- uncapped-0.1.0/tests/test_dims.py +80 -0
- uncapped-0.1.0/tests/test_split.py +269 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
First release.
|
|
6
|
+
|
|
7
|
+
- `split()` and `asplit()`: cut a capped search into windows that each fit under the cap.
|
|
8
|
+
- Dimensions: `Range` (numbers, any step), `DateRange` (dates and datetimes), `Values` (categories, one at a time or in groups).
|
|
9
|
+
- Falls back to the next dimension when one can't be narrowed any further.
|
|
10
|
+
- `fanout="auto"` sizes each split from how far over the cap a window is, to save count calls.
|
|
11
|
+
- `merge=True` (default) joins neighbouring windows that together fit under the cap, so there are fewer windows to page through. It never merges across results no dimension could reach, so counts stay exact.
|
|
12
|
+
- `cache=` keeps counts in any dict-like store (e.g. `shelve`) so an interrupted run resumes for free.
|
|
13
|
+
- `run.stats` and `UncappedWarning` report windows still over the cap and results no dimension could reach.
|
uncapped-0.1.0/LICENSE
ADDED
|
@@ -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.
|
uncapped-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
uncapped-0.1.0/README.md
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# uncapped
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/uncapped/)
|
|
4
|
+
[](https://pypi.org/project/uncapped/)
|
|
5
|
+
[](https://github.com/bhatti013/uncapped/actions/workflows/ci.yml)
|
|
6
|
+
[](https://github.com/bhatti013/uncapped/blob/main/LICENSE)
|
|
7
|
+
|
|
8
|
+
**Get every result from searches that stop at 1,000.**
|
|
9
|
+
|
|
10
|
+
Lots of APIs and sites cut you off no matter how many results match:
|
|
11
|
+
|
|
12
|
+
| Source | Most results per search |
|
|
13
|
+
|---|---|
|
|
14
|
+
| GitHub search API | 1,000 |
|
|
15
|
+
| Algolia (default `paginationLimitedTo`) | 1,000 |
|
|
16
|
+
| Elasticsearch / OpenSearch `from` + `size` (default `max_result_window`) | 10,000 |
|
|
17
|
+
| Google Places text search | 60 |
|
|
18
|
+
| Most listing, travel and job sites | a few hundred to ~1,000 |
|
|
19
|
+
|
|
20
|
+
The fix is always the same: narrow the search with filters (price, date,
|
|
21
|
+
category...) until each piece is under the limit, then page through every
|
|
22
|
+
piece. uncapped does the narrowing for you, picks how finely to cut so you
|
|
23
|
+
spend as few calls as possible, and tells you if anything still can't be
|
|
24
|
+
reached.
|
|
25
|
+
|
|
26
|
+
- Zero dependencies, Python 3.10+, fully typed
|
|
27
|
+
- Works with any client: `requests`, `httpx`, an SDK, Scrapy, SQL
|
|
28
|
+
- Sync and async
|
|
29
|
+
- Splits on numbers, dates and categories, and falls back to the next filter when one runs out
|
|
30
|
+
- Resumable: counts can be cached in any dict-like store
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install uncapped
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Example: every Rust repo with more than 1,000 stars
|
|
39
|
+
|
|
40
|
+
GitHub's search finds 2,478 of them but will only ever return 1,000. You
|
|
41
|
+
tell uncapped how to **count** results for a set of filters; it gives you back
|
|
42
|
+
windows that each fit under the cap.
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
import requests
|
|
46
|
+
from uncapped import split, DateRange
|
|
47
|
+
|
|
48
|
+
QUERY = "language:rust stars:>1000"
|
|
49
|
+
|
|
50
|
+
def with_filters(filters):
|
|
51
|
+
if "created" not in filters:
|
|
52
|
+
return QUERY
|
|
53
|
+
lo, hi = filters["created"]
|
|
54
|
+
return f"{QUERY} created:{lo}..{hi}"
|
|
55
|
+
|
|
56
|
+
def count(filters):
|
|
57
|
+
r = requests.get("https://api.github.com/search/repositories",
|
|
58
|
+
params={"q": with_filters(filters), "per_page": 1})
|
|
59
|
+
return r.json()["total_count"]
|
|
60
|
+
|
|
61
|
+
run = split(count, cap=1000, dims=[DateRange("created", "2008-01-01", "today")])
|
|
62
|
+
|
|
63
|
+
for window in run:
|
|
64
|
+
print(window.filters["created"], window.count)
|
|
65
|
+
# page through with_filters(window.filters) as usual: it's under 1,000
|
|
66
|
+
|
|
67
|
+
print(run.stats)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Real output (November 2026):
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
created repos
|
|
74
|
+
2008-01-01 .. 2017-06-04 419
|
|
75
|
+
2017-06-05 .. 2022-02-19 984
|
|
76
|
+
2022-02-20 .. 2024-06-29 513
|
|
77
|
+
2024-06-30 .. 2026-11-06 562
|
|
78
|
+
|
|
79
|
+
2,478 repos match. Without uncapped you'd get 1,000.
|
|
80
|
+
4 windows, 2,478 fetchable (100%), 7 count requests.
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Paging through those 4 windows downloaded all 2,478 repos, with no duplicates.
|
|
84
|
+
The runnable version, with rate-limit handling and download, is
|
|
85
|
+
[`examples/github_repos.py`](https://github.com/bhatti013/uncapped/blob/main/examples/github_repos.py).
|
|
86
|
+
|
|
87
|
+
## How it works
|
|
88
|
+
|
|
89
|
+
1. Count the whole search. Under the cap? Done: one window, no filters.
|
|
90
|
+
2. Over the cap? Cut the first dimension's range into parts. With the default
|
|
91
|
+
`fanout="auto"` the number of parts depends on how far over the cap it is
|
|
92
|
+
(5× over the cap → 7 parts), so you don't pay for many rounds of halving.
|
|
93
|
+
3. Count each part. Parts under the cap become windows; parts over it are cut
|
|
94
|
+
again. Empty parts are dropped.
|
|
95
|
+
4. When a dimension is down to a single value (every listing in this window
|
|
96
|
+
costs exactly 42) uncapped moves on to the next dimension.
|
|
97
|
+
5. Neighbouring windows that together still fit under the cap are merged into
|
|
98
|
+
one wider window, so you page through fewer of them. Around a dense spot
|
|
99
|
+
(2,500 listings at one price) this turns 27 windows into 6. Merged counts
|
|
100
|
+
stay exact: uncapped never merges across results it couldn't reach. Pass
|
|
101
|
+
`merge=False` to keep every window as it was split.
|
|
102
|
+
6. Windows come out in order while the plan is still being worked out, so you
|
|
103
|
+
can start fetching before it's finished.
|
|
104
|
+
|
|
105
|
+
## Dimensions
|
|
106
|
+
|
|
107
|
+
Your `count` function receives a dict keyed by dimension name. A dimension
|
|
108
|
+
that hasn't been needed yet is **left out**, so filter on it only when it's there.
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
from datetime import datetime, timedelta
|
|
112
|
+
from uncapped import Range, DateRange, Values
|
|
113
|
+
|
|
114
|
+
Range("price", 0, 5_000_000) # filters["price"] == (0, 2_499_999), inclusive
|
|
115
|
+
Range("rating", 0, 5, step=0.1) # floats work; step is the smallest gap the source sees
|
|
116
|
+
DateRange("listed", "2023-01-01", "today") # filters["listed"] == (date(...), date(...)), inclusive
|
|
117
|
+
DateRange("posted", datetime(2024, 1, 1), "now", step=timedelta(minutes=1))
|
|
118
|
+
Values("type", ["apartment", "villa", "office"]) # one value at a time: filters["type"] == "villa"
|
|
119
|
+
Values("lang", ["python", "go", "rust"], multi=True) # groups: filters["lang"] == ("python", "go")
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Give several dimensions in order of preference. uncapped splits on the first
|
|
123
|
+
until it can't, then the second:
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
split(count, cap=1000, dims=[
|
|
127
|
+
Range("price", 0, 10_000_000),
|
|
128
|
+
DateRange("listed", "2020-01-01", "today"),
|
|
129
|
+
])
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Knowing what you didn't get
|
|
133
|
+
|
|
134
|
+
uncapped never fails silently. After a run, `run.stats` has the numbers and an
|
|
135
|
+
`UncappedWarning` is raised for anything you'd miss:
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
Stats(total=48213, windows=61, fetchable=47650, capped_windows=1,
|
|
139
|
+
unaccounted=313, queries=74, cache_hits=0, max_depth=3)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
- **`capped_windows`**: windows still over the cap after every dimension is
|
|
143
|
+
down to one value. They're yielded with `window.complete == False`; you can
|
|
144
|
+
fetch the first `cap` results of each. Add another dimension or a finer
|
|
145
|
+
`step`. Use `on_capped="raise"` to stop instead, or `"ignore"` to stay quiet.
|
|
146
|
+
- **`unaccounted`**: the parts of a split added up to less than the whole.
|
|
147
|
+
That's almost always items with **no value** for the filter (a listing
|
|
148
|
+
without a price never matches any price range) or values outside the range
|
|
149
|
+
you gave. Widen the range, or fetch those items another way.
|
|
150
|
+
|
|
151
|
+
## Async
|
|
152
|
+
|
|
153
|
+
`count` can be async. Sibling counts run concurrently, up to `concurrency`:
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
import httpx
|
|
157
|
+
from uncapped import asplit, Range
|
|
158
|
+
|
|
159
|
+
async def count(filters):
|
|
160
|
+
lo, hi = filters.get("price", (None, None))
|
|
161
|
+
r = await client.get("/search", params={"min_price": lo, "max_price": hi, "limit": 1})
|
|
162
|
+
return r.json()["total"]
|
|
163
|
+
|
|
164
|
+
async for window in asplit(count, cap=1000, dims=[Range("price", 0, 10**7)], concurrency=8):
|
|
165
|
+
...
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Resuming and saving calls
|
|
169
|
+
|
|
170
|
+
Pass any dict-like object as `cache` and counts are stored there. With a
|
|
171
|
+
persistent one, a crashed or repeated run re-plans without calling the API
|
|
172
|
+
again:
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
import shelve
|
|
176
|
+
|
|
177
|
+
with shelve.open("counts") as cache:
|
|
178
|
+
for window in split(count, cap=1000, dims=dims, cache=cache):
|
|
179
|
+
...
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Counts are cached by their filters and don't expire. Delete the file when you
|
|
183
|
+
want fresh numbers.
|
|
184
|
+
|
|
185
|
+
## Recipes
|
|
186
|
+
|
|
187
|
+
**Elasticsearch / OpenSearch** (when you can't use `search_after` or a point in time):
|
|
188
|
+
|
|
189
|
+
```python
|
|
190
|
+
def count(filters):
|
|
191
|
+
query = {"bool": {"filter": []}}
|
|
192
|
+
if "ts" in filters:
|
|
193
|
+
lo, hi = filters["ts"]
|
|
194
|
+
query["bool"]["filter"].append({"range": {"ts": {"gte": lo.isoformat(), "lte": hi.isoformat()}}})
|
|
195
|
+
return es.count(index="events", query=query)["count"]
|
|
196
|
+
|
|
197
|
+
split(count, cap=10_000, dims=[DateRange("ts", datetime(2024, 1, 1), "now", step=timedelta(seconds=1))])
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
**A listing site's JSON API** with min/max price parameters:
|
|
201
|
+
|
|
202
|
+
```python
|
|
203
|
+
def count(filters):
|
|
204
|
+
params = {"category": "cars", "page_size": 1}
|
|
205
|
+
if "price" in filters:
|
|
206
|
+
params["price_min"], params["price_max"] = filters["price"]
|
|
207
|
+
return session.get(SEARCH_URL, params=params).json()["meta"]["total"]
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
## FAQ
|
|
211
|
+
|
|
212
|
+
**Why not just ask for page 11?** The cap is enforced by the server. Past it
|
|
213
|
+
you get an error or the same results again.
|
|
214
|
+
|
|
215
|
+
**My source's counts are estimates.** Fine: windows are still correct, but
|
|
216
|
+
`unaccounted` may show small numbers from rounding. Leave some headroom by
|
|
217
|
+
passing a slightly smaller `cap` (e.g. `950`).
|
|
218
|
+
|
|
219
|
+
**Data changes while I fetch.** Windows are planned from the counts at that
|
|
220
|
+
moment. New items in a window may push it a little over the cap; a smaller
|
|
221
|
+
`cap` gives you headroom here too.
|
|
222
|
+
|
|
223
|
+
**Is this allowed?** uncapped only decides which searches to run. Respect the
|
|
224
|
+
terms and rate limits of whatever you query.
|
|
225
|
+
|
|
226
|
+
## License
|
|
227
|
+
|
|
228
|
+
MIT
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Every repository matching a GitHub search, past GitHub's 1,000-result limit.
|
|
2
|
+
|
|
3
|
+
GitHub's search API returns at most 1,000 results per query, however many
|
|
4
|
+
match. This splits the query by creation date until every window fits, then
|
|
5
|
+
(optionally) pages through each window.
|
|
6
|
+
|
|
7
|
+
python examples/github_repos.py "language:rust stars:>1000"
|
|
8
|
+
python examples/github_repos.py "language:rust stars:>1000" --fetch rust.jsonl
|
|
9
|
+
|
|
10
|
+
Set GITHUB_TOKEN for a higher rate limit (30 searches/minute instead of 10).
|
|
11
|
+
Standard library only, besides uncapped.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
import time
|
|
19
|
+
from urllib.error import HTTPError
|
|
20
|
+
from urllib.parse import urlencode
|
|
21
|
+
from urllib.request import Request, urlopen
|
|
22
|
+
|
|
23
|
+
from uncapped import DateRange, split
|
|
24
|
+
|
|
25
|
+
API = "https://api.github.com/search/repositories"
|
|
26
|
+
CAP = 1000 # GitHub search never returns more than this
|
|
27
|
+
PER_PAGE = 100
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def search(q, page=1, per_page=PER_PAGE):
|
|
31
|
+
headers = {"Accept": "application/vnd.github+json", "User-Agent": "uncapped-example"}
|
|
32
|
+
if os.environ.get("GITHUB_TOKEN"):
|
|
33
|
+
headers["Authorization"] = f"Bearer {os.environ['GITHUB_TOKEN']}"
|
|
34
|
+
url = f"{API}?{urlencode({'q': q, 'page': page, 'per_page': per_page})}"
|
|
35
|
+
while True:
|
|
36
|
+
try:
|
|
37
|
+
with urlopen(Request(url, headers=headers), timeout=30) as resp:
|
|
38
|
+
return json.load(resp)
|
|
39
|
+
except HTTPError as e:
|
|
40
|
+
if e.code not in (403, 429):
|
|
41
|
+
raise
|
|
42
|
+
wait = int(e.headers.get("retry-after") or 0)
|
|
43
|
+
if not wait and e.headers.get("x-ratelimit-remaining") == "0":
|
|
44
|
+
wait = int(e.headers["x-ratelimit-reset"]) - int(time.time()) + 1
|
|
45
|
+
if wait <= 0:
|
|
46
|
+
raise
|
|
47
|
+
print(f" rate limited, waiting {wait}s", file=sys.stderr)
|
|
48
|
+
time.sleep(wait)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def main():
|
|
52
|
+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
53
|
+
parser.add_argument("query", help='GitHub search query, e.g. "language:rust stars:>1000"')
|
|
54
|
+
parser.add_argument("--since", default="2008-01-01", help="earliest creation date")
|
|
55
|
+
parser.add_argument("--fetch", metavar="FILE", help="also download every repo to this JSONL file")
|
|
56
|
+
args = parser.parse_args()
|
|
57
|
+
|
|
58
|
+
def with_filters(filters):
|
|
59
|
+
if "created" not in filters:
|
|
60
|
+
return args.query
|
|
61
|
+
lo, hi = filters["created"]
|
|
62
|
+
return f"{args.query} created:{lo}..{hi}"
|
|
63
|
+
|
|
64
|
+
def count(filters):
|
|
65
|
+
return search(with_filters(filters), per_page=1)["total_count"]
|
|
66
|
+
|
|
67
|
+
run = split(count, cap=CAP, dims=[DateRange("created", args.since, "today")])
|
|
68
|
+
|
|
69
|
+
print(f"{'created':<25} {'repos':>7}")
|
|
70
|
+
windows = []
|
|
71
|
+
for w in run:
|
|
72
|
+
lo, hi = w.filters.get("created", ("(all)", ""))
|
|
73
|
+
print(f"{lo!s:>10} .. {hi!s:<11} {w.count:>7,}")
|
|
74
|
+
windows.append(w)
|
|
75
|
+
|
|
76
|
+
s = run.stats
|
|
77
|
+
print(
|
|
78
|
+
f"\n{s.total:,} repos match. Without uncapped you'd get {min(s.total, CAP):,}.\n"
|
|
79
|
+
f"{s.windows} windows, {s.fetchable:,} fetchable ({s.coverage:.0%}), "
|
|
80
|
+
f"{s.queries} count requests."
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
if not args.fetch:
|
|
84
|
+
return
|
|
85
|
+
seen = set()
|
|
86
|
+
with open(args.fetch, "w") as out:
|
|
87
|
+
for w in windows:
|
|
88
|
+
q = with_filters(w.filters)
|
|
89
|
+
for page in range(1, -(-min(w.count, CAP) // PER_PAGE) + 1):
|
|
90
|
+
for repo in search(q, page=page)["items"]:
|
|
91
|
+
if repo["id"] not in seen:
|
|
92
|
+
seen.add(repo["id"])
|
|
93
|
+
out.write(json.dumps(repo) + "\n")
|
|
94
|
+
print(f" fetched {len(seen):,} / {s.total:,}", file=sys.stderr)
|
|
95
|
+
print(f"\nWrote {len(seen):,} unique repos to {args.fetch}")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
if __name__ == "__main__":
|
|
99
|
+
main()
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "uncapped"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Get every result from APIs and sites that stop at 1,000 results, by splitting the search into windows that fit under the cap."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
authors = [{ name = "Abdul Wahab" }]
|
|
14
|
+
keywords = ["pagination", "search", "api", "scraping", "result limit", "github search", "crawler", "data extraction"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Programming Language :: Python :: 3.14",
|
|
26
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
27
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
28
|
+
"Typing :: Typed",
|
|
29
|
+
]
|
|
30
|
+
dependencies = []
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://github.com/bhatti013/uncapped"
|
|
34
|
+
Issues = "https://github.com/bhatti013/uncapped/issues"
|
|
35
|
+
|
|
36
|
+
[tool.hatch.version]
|
|
37
|
+
path = "src/uncapped/__init__.py"
|
|
38
|
+
|
|
39
|
+
[tool.hatch.build.targets.sdist]
|
|
40
|
+
include = ["src", "tests", "examples", "README.md", "LICENSE", "CHANGELOG.md"]
|
|
41
|
+
|
|
42
|
+
[tool.pytest.ini_options]
|
|
43
|
+
testpaths = ["tests"]
|
|
44
|
+
filterwarnings = ["error"]
|