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.
@@ -0,0 +1,10 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .pytest_cache/
8
+ .coverage
9
+ htmlcov/
10
+ .DS_Store
@@ -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.
@@ -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,228 @@
1
+ # uncapped
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/uncapped)](https://pypi.org/project/uncapped/)
4
+ [![Python](https://img.shields.io/pypi/pyversions/uncapped)](https://pypi.org/project/uncapped/)
5
+ [![CI](https://github.com/bhatti013/uncapped/actions/workflows/ci.yml/badge.svg)](https://github.com/bhatti013/uncapped/actions/workflows/ci.yml)
6
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](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"]