Skip to content

Commit 551da7f

Browse files
committed
pagination tools
1 parent c1e352f commit 551da7f

File tree

5 files changed

+194
-118
lines changed

5 files changed

+194
-118
lines changed

packages/common-library/src/common_library/iter_tools.py

Lines changed: 0 additions & 41 deletions
This file was deleted.
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
from collections.abc import Iterable
2+
from typing import Annotated
3+
4+
from pydantic import BaseModel, ConfigDict, Field, NonNegativeInt, PositiveInt
5+
6+
7+
class PageParams(BaseModel):
8+
offset_initial: Annotated[NonNegativeInt, Field(frozen=True)] = 0
9+
offset_current: NonNegativeInt = 0 # running offset
10+
limit: Annotated[PositiveInt, Field(frozen=True)]
11+
total_number_of_items: int | None = None
12+
13+
model_config = ConfigDict(validate_assignment=True)
14+
15+
@property
16+
def offset(self) -> NonNegativeInt:
17+
return self.offset_current
18+
19+
def has_items_left(self) -> bool:
20+
return (
21+
self.total_number_of_items is None
22+
or self.offset_current < self.total_number_of_items
23+
)
24+
25+
def total_number_of_pages(self) -> NonNegativeInt:
26+
assert self.total_number_of_items # nosec
27+
num_items = self.total_number_of_items - self.offset_initial
28+
return num_items // self.limit + (1 if num_items % self.limit else 0)
29+
30+
31+
def iter_pagination_params(
32+
offset: NonNegativeInt = 0,
33+
limit: PositiveInt = 100,
34+
total_number_of_items: NonNegativeInt | None = None,
35+
) -> Iterable[PageParams]:
36+
37+
kwargs = {}
38+
if total_number_of_items:
39+
kwargs["total_number_of_items"] = total_number_of_items
40+
41+
page_params = PageParams(
42+
offset_initial=offset, offset_current=offset, limit=limit, **kwargs
43+
)
44+
45+
assert page_params.offset_current == page_params.offset_initial # nosec
46+
47+
total_count_before = page_params.total_number_of_items
48+
page_index = 0
49+
50+
while page_params.has_items_left():
51+
52+
yield page_params
53+
54+
if page_params.total_number_of_items is None:
55+
msg = "Must be updated at least before the first iteration, i.e. page_args.total_number_of_items = total_count"
56+
raise RuntimeError(msg)
57+
58+
if (
59+
total_count_before
60+
and total_count_before != page_params.total_number_of_items
61+
):
62+
msg = (
63+
f"total_number_of_items cannot change on every iteration: before={total_count_before}, now={page_params.total_number_of_items}."
64+
"WARNING: the size of the paginated collection might be changing while it is being iterated?"
65+
)
66+
raise RuntimeError(msg)
67+
68+
if page_index == 0:
69+
total_count_before = page_params.total_number_of_items
70+
71+
page_params.offset_current += limit
72+
assert page_params.offset == page_params.offset_current # nosec

packages/common-library/tests/test_iter_tools.py

Lines changed: 0 additions & 50 deletions
This file was deleted.
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# pylint: disable=redefined-outer-name
2+
# pylint: disable=unused-argument
3+
# pylint: disable=unused-variable
4+
# pylint: disable=too-many-arguments
5+
6+
import asyncio
7+
from collections.abc import Callable
8+
9+
import pytest
10+
from common_library.pagination_tools import iter_pagination_params
11+
from pydantic import ValidationError
12+
13+
14+
@pytest.fixture
15+
def all_items() -> list[int]:
16+
return list(range(11))
17+
18+
19+
@pytest.fixture
20+
async def get_page(all_items: list[int]) -> Callable:
21+
async def _get_page(offset, limit) -> tuple[list[int], int]:
22+
await asyncio.sleep(0)
23+
return all_items[offset : offset + limit], len(all_items)
24+
25+
return _get_page
26+
27+
28+
@pytest.mark.parametrize("limit", [2, 3, 5])
29+
@pytest.mark.parametrize("offset", [0, 1, 5])
30+
async def test_iter_pages_args(
31+
limit: int, offset: int, get_page: Callable, all_items: list[int]
32+
):
33+
34+
last_page = [None] * limit
35+
36+
num_items = len(all_items) - offset
37+
expected_num_pages = num_items // limit + (1 if num_items % limit else 0)
38+
39+
num_pages = 0
40+
for page_index, page_args in enumerate(iter_pagination_params(offset, limit)):
41+
42+
page_items, page_args.total_number_of_items = await get_page(
43+
page_args.offset_current, page_args.limit
44+
)
45+
46+
assert set(last_page) != set(page_items)
47+
last_page = list(page_items)
48+
49+
# contains sub-sequence
50+
assert str(page_items)[1:-1] in str(all_items)[1:-1]
51+
52+
num_pages = page_index + 1
53+
54+
assert last_page[-1] == all_items[-1]
55+
assert num_pages == expected_num_pages
56+
57+
assert not page_args.has_items_left()
58+
assert page_args.total_number_of_pages() == num_pages
59+
60+
61+
@pytest.mark.parametrize("limit", [-1, 0])
62+
@pytest.mark.parametrize("offset", [-1])
63+
def test_iter_pages_args_invalid(limit: int, offset: int):
64+
65+
with pytest.raises(ValidationError): # noqa: PT012
66+
for _ in iter_pagination_params(offset=offset, limit=limit):
67+
pass
68+
69+
70+
def test_fails_if_total_number_of_items_not_set():
71+
with pytest.raises( # noqa: PT012
72+
RuntimeError,
73+
match="page_args.total_number_of_items = total_count",
74+
):
75+
for _ in iter_pagination_params(limit=2):
76+
pass
77+
78+
79+
def test_fails_if_total_number_of_items_changes():
80+
with pytest.raises( # noqa: PT012
81+
RuntimeError,
82+
match="total_number_of_items cannot change on every iteration",
83+
):
84+
for page_params in iter_pagination_params(limit=2, total_number_of_items=4):
85+
assert page_params.total_number_of_items == 4
86+
page_params.total_number_of_items += 1

services/web/server/src/simcore_service_webserver/projects/_trash_service.py

Lines changed: 36 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import arrow
66
from aiohttp import web
7+
from common_library.pagination_tools import iter_pagination_params
78
from models_library.basic_types import IDStr
89
from models_library.products import ProductName
910
from models_library.projects import ProjectID
@@ -145,34 +146,42 @@ async def list_trashed_projects(
145146
"""
146147
Lists all projects that were trashed until a specific datetime.
147148
"""
148-
projects, _ = await _crud_api_read.list_projects_full_depth(
149-
app,
150-
user_id=user_id,
151-
product_name=product_name,
152-
trashed=True,
153-
tag_ids_list=[],
154-
offset=0,
155-
limit=100, # FIXME: this is only the first 100. redo with yield
156-
order_by=OrderBy(field=IDStr("trashed"), direction=OrderDirection.ASC),
157-
search_by_multi_columns=None,
158-
search_by_project_name=None,
159-
)
149+
trashed_projects: list[ProjectID] = []
150+
151+
for page_params in iter_pagination_params(limit=100):
152+
(
153+
projects,
154+
page_params.total_number_of_items,
155+
) = await _crud_api_read.list_projects_full_depth(
156+
app,
157+
user_id=user_id,
158+
product_name=product_name,
159+
trashed=True,
160+
tag_ids_list=[],
161+
offset=page_params.offset,
162+
limit=page_params.limit,
163+
order_by=OrderBy(field=IDStr("trashed"), direction=OrderDirection.ASC),
164+
search_by_multi_columns=None,
165+
search_by_project_name=None,
166+
)
160167

161-
# NOTE; this can be done at the database level when projects_repo is refactored
162-
# by defining a custom trash_filter that permits some flexibility in the filtering options
163-
trashed_projects = []
164-
for project in projects:
165-
trashed_at, trashed_by, trashed_explicitly = _get_trashed_fields(project)
166-
167-
if (
168-
trashed_at
169-
and (until_equal_datetime is None or trashed_at < until_equal_datetime)
170-
and trashed_by == user_id
171-
and trashed_explicitly
172-
):
173-
trashed_projects.append(project)
174-
175-
return [project["uuid"] for project in trashed_projects]
168+
# NOTE: post filtering because for the moment, i do not want ot modify the interface
169+
# of _crud_api_read.list_projects_full_depth.
170+
# This could not be done at the database level when `projects_repo` is refactored
171+
# by defining a custom trash_filter that permits some flexibility in the filtering
172+
# options
173+
for project in projects:
174+
trashed_at, trashed_by, trashed_explicitly = _get_trashed_fields(project)
175+
176+
if (
177+
trashed_at
178+
and (until_equal_datetime is None or trashed_at < until_equal_datetime)
179+
and trashed_by == user_id
180+
and trashed_explicitly
181+
):
182+
trashed_projects.append(project["uuid"])
183+
184+
return trashed_projects
176185

177186

178187
async def delete_trashed_project(

0 commit comments

Comments
 (0)