Skip to content

Commit 7a697c5

Browse files
fix: page the Kaggle search by number, and require kaggle 2.x
Kaggle ignores page_size and never fills next_page_token, so the search returned twenty rows whatever was asked and its cursor was always None: the Hub could never load a second page. The page parameter does work, so the cursor is now the page number as a string, the way the Zenodo source already does it, and a page shorter than Kaggle's fixed size is read as the last one. Entries are not trimmed to the requested limit, because the next page starts where this one ended and a trimmed row is never served again. The credential and the source rely on the kaggle 2.x token API, which the old ">=1.7.4.5" floor did not guarantee. With Python 3.10 gone the lock already resolves 2.2.4 everywhere; the pin makes the requirement explicit.
1 parent 22a91e2 commit 7a697c5

4 files changed

Lines changed: 107 additions & 65 deletions

File tree

DashAI/back/dataset_sources/kaggle_dataset_source.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@
1616

1717
log = logging.getLogger(__name__)
1818

19+
# How many datasets Kaggle puts on one search page. It pages by number, ignores
20+
# ``page_size`` and never fills ``next_page_token``, so the cursor is the page
21+
# number, as in the Zenodo source, and a page shorter than this is the last one.
22+
_KAGGLE_PAGE_SIZE: Final[int] = 20
23+
1924

2025
def _import_kaggle():
2126
"""Import the ``kaggle`` module, suppressing its import time auth noise.
@@ -143,10 +148,11 @@ def search(
143148
query : str
144149
Free text search string.
145150
limit : int, optional
146-
Maximum number of results, by default 20.
151+
Requested page size, by default 20. Passed to Kaggle, which today
152+
serves ``_KAGGLE_PAGE_SIZE`` datasets a page whatever is asked.
147153
cursor : str or None, optional
148-
Kaggle pagination token returned by the previous call. ``None``
149-
fetches the first page.
154+
Page number returned by the previous call as ``next_cursor``.
155+
``None`` fetches the first page.
150156
**filters : Any
151157
Supported keys:
152158
sort_by (str): Kaggle dataset sort (e.g. ``"hottest"``).
@@ -162,13 +168,14 @@ def search(
162168
"""
163169
kaggle = _import_kaggle()
164170
try:
171+
page = int(cursor) if cursor else 1
165172
sort_by = filters.get("sort_by") or "hottest"
166173
tag_ids = filters.get("tags")
167174
if isinstance(tag_ids, list):
168175
tag_ids = ",".join(tag_ids)
169176
params: dict[str, Any] = {
170177
"search": query or None,
171-
"page_token": cursor or None,
178+
"page": page,
172179
"page_size": limit,
173180
"sort_by": sort_by,
174181
}
@@ -178,7 +185,12 @@ def search(
178185
params[key] = value
179186
response = kaggle.api.dataset_list_with_response(**params)
180187
entries = [self._to_entry(item) for item in (response.datasets or [])]
181-
next_cursor = response.next_page_token or None
188+
# Kaggle answers with a full page or the tail of the results, never
189+
# with a token, so a full page is the only sign of a next one. The
190+
# entries are not trimmed to ``limit``: the next page starts where
191+
# this one ended, so anything cut here would never be served again.
192+
has_next = len(entries) >= min(limit, _KAGGLE_PAGE_SIZE)
193+
next_cursor = str(page + 1) if has_next else None
182194
return SearchPage(entries=entries, next_cursor=next_cursor)
183195
except Exception:
184196
log.exception("Error searching Kaggle datasets")

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ dependencies = [
7878
"openml",
7979
"oslo.concurrency",
8080
"cryptography>=49.0.0",
81-
"kaggle>=1.7.4.5",
81+
"kaggle>=2",
8282
"grad-cam>=1.5.5",
8383
"dice-ml>=0.12",
8484
"lime>=0.2.0.1",

tests/back/dataset_sources/test_kaggle_dataset_source.py

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -62,15 +62,14 @@ def test_search_returns_dataset_entries():
6262
total_bytes=15347,
6363
)
6464
]
65-
response.next_page_token = ""
6665
api.dataset_list_with_response.return_value = response
6766

6867
with fake_kaggle(api):
6968
source = _make_source()
7069
page = source.search("iris", limit=5)
7170

7271
api.dataset_list_with_response.assert_called_once_with(
73-
search="iris", page_token=None, page_size=5, sort_by="hottest"
72+
search="iris", page=1, page_size=5, sort_by="hottest"
7473
)
7574
assert isinstance(page, SearchPage)
7675
assert len(page.entries) == 1
@@ -84,28 +83,59 @@ def test_search_returns_dataset_entries():
8483
assert page.next_cursor is None
8584

8685

87-
def test_search_passes_cursor_and_exposes_next_cursor():
86+
def test_search_reads_the_cursor_as_a_page_number_and_a_full_page_continues():
87+
# Kaggle pages by number and never fills next_page_token, so the cursor is
88+
# the page and a full page is the only sign that another one follows.
8889
api = MagicMock()
8990
response = MagicMock()
90-
response.datasets = [_dataset("owner/repo")]
91-
response.next_page_token = "tok123"
91+
response.datasets = [_dataset(f"owner/repo{i}") for i in range(20)]
9292
api.dataset_list_with_response.return_value = response
9393

9494
with fake_kaggle(api):
9595
source = _make_source()
96-
page = source.search("q", limit=20, cursor="prevtok")
96+
page = source.search("q", limit=20, cursor="2")
9797

9898
api.dataset_list_with_response.assert_called_once_with(
99-
search="q", page_token="prevtok", page_size=20, sort_by="hottest"
99+
search="q", page=2, page_size=20, sort_by="hottest"
100100
)
101-
assert page.next_cursor == "tok123"
101+
assert len(page.entries) == 20
102+
assert page.next_cursor == "3"
103+
104+
105+
def test_search_treats_a_short_page_as_the_last_one():
106+
api = MagicMock()
107+
response = MagicMock()
108+
response.datasets = [_dataset(f"owner/repo{i}") for i in range(7)]
109+
api.dataset_list_with_response.return_value = response
110+
111+
with fake_kaggle(api):
112+
source = _make_source()
113+
page = source.search("q", limit=20, cursor="3")
114+
115+
assert len(page.entries) == 7
116+
assert page.next_cursor is None
117+
118+
119+
def test_search_does_not_trim_a_page_below_the_requested_limit():
120+
# Kaggle ignores page_size and the next page starts where this one ended,
121+
# so cutting the page down to ``limit`` would lose rows for good.
122+
api = MagicMock()
123+
response = MagicMock()
124+
response.datasets = [_dataset(f"owner/repo{i}") for i in range(20)]
125+
api.dataset_list_with_response.return_value = response
126+
127+
with fake_kaggle(api):
128+
source = _make_source()
129+
page = source.search("q", limit=3)
130+
131+
assert len(page.entries) == 20
132+
assert page.next_cursor == "2"
102133

103134

104135
def test_search_uses_slug_as_name_when_title_missing():
105136
api = MagicMock()
106137
response = MagicMock()
107138
response.datasets = [_dataset("uciml/iris", title="")]
108-
response.next_page_token = ""
109139
api.dataset_list_with_response.return_value = response
110140

111141
with fake_kaggle(api):

0 commit comments

Comments
 (0)