Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions openml/utils/_openml.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
import openml._api_calls
import openml.exceptions

MAX_LISTING_BATCH_SIZE = 1_000

# Avoid import cycles: https://mypy.readthedocs.io/en/latest/common_issues.html#import-cycles
if TYPE_CHECKING:
from collections.abc import Callable, Iterable, Mapping, Sequence, Sized
Expand Down Expand Up @@ -251,7 +253,7 @@ def _list_all( # noqa: C901
*,
limit: int | None = None,
offset: int | None = None,
batch_size: int | None = 10_000,
batch_size: int | None = MAX_LISTING_BATCH_SIZE,
) -> list[_SizedT]:
"""Helper to handle paged listing requests.

Expand All @@ -265,7 +267,7 @@ def _list_all( # noqa: C901
Call listing, e.g. list_evaluations. Takes two positional
arguments: batch_size and offset.
batch_size : int, optional
The batch size to use for the listing call.
The batch size to use for the listing call. Requests are capped at 1,000 items.
offset : int, optional
The initial offset to use for the listing call.
limit : int, optional
Expand All @@ -281,7 +283,7 @@ def _list_all( # noqa: C901
results: list[_SizedT] = []

offset = offset if offset is not None else 0
batch_size = batch_size if batch_size is not None else 10_000
batch_size = batch_size if batch_size is not None else MAX_LISTING_BATCH_SIZE

LIMIT = limit
BATCH_SIZE_ORIG = batch_size
Expand All @@ -292,6 +294,8 @@ def _list_all( # noqa: C901
if not isinstance(BATCH_SIZE_ORIG, int):
raise ValueError(f"'batch_size' should be an integer but got {BATCH_SIZE_ORIG}")

BATCH_SIZE_ORIG = min(BATCH_SIZE_ORIG, MAX_LISTING_BATCH_SIZE)

if (LIMIT is not None) and (not isinstance(LIMIT, int)) and (not np.isinf(LIMIT)):
raise ValueError(f"'limit' should be an integer or inf but got {LIMIT}")

Expand Down
200 changes: 200 additions & 0 deletions tests/test_utils/test_listing_pagination.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
# License: BSD 3-Clause
# mypy: disable-error-code=untyped-decorator
from __future__ import annotations

import unittest.mock
from typing import Any, Literal

import pandas as pd
import pytest

import openml


@pytest.mark.parametrize("batch_size", [None, 10_000])
def test_list_all_caps_batch_size(batch_size: int | None) -> None:
calls: list[tuple[int, int]] = []

def listing_call(size: int, offset: int) -> list[int]:
calls.append((size, offset))
return list(range(offset, min(offset + size, 2_500)))

kwargs = {} if batch_size is None else {"batch_size": batch_size}
batches = openml.utils._list_all(listing_call, limit=2_500, **kwargs)

assert calls == [(1_000, 0), (1_000, 1_000), (500, 2_000)]
assert sum(len(batch) for batch in batches) == 2_500


@pytest.mark.parametrize(
("batch_size", "limit", "offset", "expected_calls"),
[
(None, 2_500, 125, [(1_000, 125), (1_000, 1_125), (500, 2_125)]),
(10_000, 2_500, 125, [(1_000, 125), (1_000, 1_125), (500, 2_125)]),
(None, 250, 125, [(250, 125)]),
(10_000, 2_000, 125, [(1_000, 125), (1_000, 1_125)]),
(400, 1_050, 125, [(400, 125), (400, 525), (250, 925)]),
],
)
def test_list_all_preserves_limit_and_offset(
batch_size: int | None,
limit: int,
offset: int,
expected_calls: list[tuple[int, int]],
) -> None:
listing_call = unittest.mock.Mock(
side_effect=lambda size, start: list(range(start, start + size)),
)

batches = openml.utils._list_all(
listing_call,
batch_size=batch_size,
limit=limit,
offset=offset,
)

assert listing_call.call_args_list == [unittest.mock.call(*call) for call in expected_calls]
assert [item for batch in batches for item in batch] == list(range(offset, offset + limit))


@pytest.mark.parametrize("limit", [None, 5_000])
@pytest.mark.parametrize("available", [0, 1_000, 1_250])
def test_list_all_stops_when_results_end(limit: int | None, available: int) -> None:
offset = 125
listing_call = unittest.mock.Mock(
side_effect=lambda size, start: list(range(start, min(start + size, offset + available))),
)

batches = openml.utils._list_all(
listing_call,
batch_size=10_000,
limit=limit,
offset=offset,
)

expected_calls = [unittest.mock.call(1_000, offset)]
if available >= 1_000:
expected_calls.append(unittest.mock.call(1_000, offset + 1_000))
assert listing_call.call_args_list == expected_calls
assert [item for batch in batches for item in batch] == list(range(offset, offset + available))


def test_list_all_stops_on_no_result() -> None:
listing_call = unittest.mock.Mock(
side_effect=[list(range(125, 1_125)), openml.exceptions.OpenMLServerNoResult("No results")],
)

batches = openml.utils._list_all(listing_call, batch_size=10_000, offset=125)

assert listing_call.call_args_list == [
unittest.mock.call(1_000, 125),
unittest.mock.call(1_000, 1_125),
]
assert batches == [list(range(125, 1_125))]


def test_list_datasets_pages_preserve_filters() -> None:
def listing_call(size: int, offset: int, **filters: Any) -> pd.DataFrame:
assert filters["tag"] == "pagination-test"
assert filters["status"] == "active"
assert filters["data_name"] == "example"
return pd.DataFrame({"did": range(offset, offset + size)})

with unittest.mock.patch(
"openml.datasets.functions._list_datasets",
side_effect=listing_call,
) as page_fetch:
datasets = openml.datasets.list_datasets(
size=2_500,
offset=125,
tag="pagination-test",
status="active",
data_name="example",
)

assert [call.args for call in page_fetch.call_args_list] == [
(1_000, 125),
(1_000, 1_125),
(500, 2_125),
]
assert isinstance(datasets, pd.DataFrame)
assert datasets["did"].tolist() == list(range(125, 2_625))


def test_list_runs_pages_preserve_filters() -> None:
def listing_call(size: int, offset: int, **filters: Any) -> pd.DataFrame:
assert filters["tag"] == "pagination-test"
assert filters["task"] == [42]
return pd.DataFrame({"run_id": range(offset, offset + size)})

with unittest.mock.patch.object(
openml._backend.run,
"list",
side_effect=listing_call,
) as page_fetch:
runs = openml.runs.list_runs(size=2_500, offset=125, tag="pagination-test", task=[42])

assert [call.args for call in page_fetch.call_args_list] == [
(1_000, 125),
(1_000, 1_125),
(500, 2_125),
]
assert isinstance(runs, pd.DataFrame)
assert runs["run_id"].tolist() == list(range(125, 2_625))


@pytest.mark.parametrize("output_format", ["object", "dataframe"])
def test_list_evaluations_pages_preserve_output(
output_format: Literal["object", "dataframe"],
) -> None:
def listing_call(
size: int, offset: int, **filters: Any
) -> list[openml.evaluations.OpenMLEvaluation]:
assert filters["function"] == "predictive_accuracy"
assert filters["tag"] == "pagination-test"
return [
openml.evaluations.OpenMLEvaluation(
run_id=run_id,
task_id=42,
setup_id=1,
flow_id=1,
flow_name="example",
data_id=1,
data_name="example",
function="predictive_accuracy",
upload_time="2026-01-01",
uploader=1,
uploader_name="example",
value=0.8,
values=None,
)
for run_id in range(offset, offset + size)
]

with unittest.mock.patch.object(
openml._backend.evaluation,
"list",
side_effect=listing_call,
) as page_fetch:
evaluations = openml.evaluations.list_evaluations(
"predictive_accuracy",
size=2_500,
offset=125,
tag="pagination-test",
output_format=output_format,
)

assert [call.args for call in page_fetch.call_args_list] == [
(1_000, 125),
(1_000, 1_125),
(500, 2_125),
]
if output_format == "dataframe":
assert isinstance(evaluations, pd.DataFrame)
assert evaluations["run_id"].tolist() == list(range(125, 2_625))
else:
assert isinstance(evaluations, dict)
assert list(evaluations) == list(range(125, 2_625))
assert all(
isinstance(item, openml.evaluations.OpenMLEvaluation) for item in evaluations.values()
)