From f8bf29f9c25b4b77377bdc2decb15202299559e0 Mon Sep 17 00:00:00 2001 From: pdmurray Date: Tue, 17 Dec 2024 18:31:12 -0800 Subject: [PATCH] Pagination works, but I need a way to get the last values from the results; InstantiatedAttribute can't be used to get values from a db table entry? --- .../conda_store_server/_internal/schema.py | 8 +- .../_internal/server/views/api.py | 8 +- .../_internal/server/views/pagination.py | 36 +++++++-- .../tests/_internal/server/views/test_api.py | 74 +++++++++++++++++-- conda-store-server/tests/conftest.py | 46 ++++++++++++ 5 files changed, 154 insertions(+), 18 deletions(-) diff --git a/conda-store-server/conda_store_server/_internal/schema.py b/conda-store-server/conda_store_server/_internal/schema.py index 927efb4ae..679ddd250 100644 --- a/conda-store-server/conda_store_server/_internal/schema.py +++ b/conda-store-server/conda_store_server/_internal/schema.py @@ -499,10 +499,10 @@ class APIPaginatedResponse(APIResponse): class APICursorPaginatedResponse(BaseModel): - data: Optional[Any] + data: Optional[Any] = None status: APIStatus - message: Optional[str] - cursor: Optional[str] + message: Optional[str] = None + cursor: Optional[str] = None count: int @@ -571,7 +571,7 @@ class APIDeleteNamespaceRole(BaseModel): # GET /api/v1/environment class APIListEnvironment(APICursorPaginatedResponse): - data: List[Environment] + data: List[Environment] = [] # GET /api/v1/environment/{namespace}/{name} diff --git a/conda-store-server/conda_store_server/_internal/server/views/api.py b/conda-store-server/conda_store_server/_internal/server/views/api.py index 9c8f22bff..95bc5f79f 100644 --- a/conda-store-server/conda_store_server/_internal/server/views/api.py +++ b/conda-store-server/conda_store_server/_internal/server/views/api.py @@ -744,11 +744,13 @@ async def api_list_environments( query=query, ordering_metadata=OrderingMetadata( order_names=["namespace", "name"], - column_names=["namespace.name", "name"], + # column_names=['namespace.name', 'name'], + column_names=[orm.Namespace.name, orm.Environment.name], ), cursor=cursor, - order_by=paginated_args["sort_by"], - limit=paginated_args["limit"], + order_by=paginated_args.sort_by, + order=paginated_args.order, + limit=paginated_args.limit, ) return schema.APIListEnvironment( diff --git a/conda-store-server/conda_store_server/_internal/server/views/pagination.py b/conda-store-server/conda_store_server/_internal/server/views/pagination.py index 5d76e401f..689225158 100644 --- a/conda-store-server/conda_store_server/_internal/server/views/pagination.py +++ b/conda-store-server/conda_store_server/_internal/server/views/pagination.py @@ -2,7 +2,7 @@ import base64 import operator -from typing import Any, TypedDict +from typing import Any import pydantic from fastapi import HTTPException @@ -23,13 +23,13 @@ class Cursor(pydantic.BaseModel): last_value: dict[str, str] | None = {} def dump(self) -> str: - return base64.b64encode(self.model_dump_json()) + return base64.b64encode(self.model_dump_json().encode("utf8")) @classmethod def load(cls, data: str | None = None) -> Cursor | None: if data is None: return None - return cls.from_json(base64.b64decode(data)) + return cls.from_json(base64.b64decode(data).decode("utf8")) def get_last_values(self, order_names: list[str]) -> list[Any]: if order_names: @@ -106,6 +106,7 @@ def paginate( ) ) + breakpoint() query = query.order_by( *[order_func(col) for col in columns], order_func(queried_type.id) ) @@ -125,11 +126,33 @@ def paginate( return (data, next_cursor) -class CursorPaginatedArgs(TypedDict): +class CursorPaginatedArgs(pydantic.BaseModel): limit: int order: str sort_by: list[str] + @pydantic.field_validator("sort_by") + def validate_sort_by(cls, v: list[str]) -> list[str]: + """Validate the columns to sort by. + + FastAPI doesn't support lists in query parameters, so if the + `sort_by` value is a single-element list, assume that this + could be a comma-separated list. No harm in attempting to split + this by commas. + + Parameters + ---------- + v : list[str] + + + Returns + ------- + list[str] + """ + if len(v) == 1: + v = v[0].split(",") + return v + class OrderingMetadata: def __init__( @@ -173,6 +196,7 @@ def get_requested_columns( if order_by: for order_name in order_by: idx = self.order_names.index(order_name) + # columns.append(text(self.column_names[idx])) columns.append(self.column_names[idx]) return columns @@ -199,10 +223,12 @@ def get_attr_values( A mapping between the `order_by` values and the attribute values on `obj` """ + breakpoint() values = {} for order_name in order_by: idx = self.order_names.index(order_name) - values[order_name] = get_nested_attribute(obj, self.column_names[idx]) + attr = self.column_names[idx] + values[order_name] = get_nested_attribute(obj, attr) return values diff --git a/conda-store-server/tests/_internal/server/views/test_api.py b/conda-store-server/tests/_internal/server/views/test_api.py index 4f4bb350d..5d8d6505a 100644 --- a/conda-store-server/tests/_internal/server/views/test_api.py +++ b/conda-store-server/tests/_internal/server/views/test_api.py @@ -1072,16 +1072,78 @@ def test_default_conda_store_dir(): assert dir == f"/home/{user}/.local/share/conda-store" -def test_api_list_environments( +@pytest.mark.parametrize( + "order", + [ + "asc", + "desc", + ], +) +def test_api_list_environments_by_name( conda_store_server, testclient, - seed_conda_store, + seed_conda_store_big, authenticate, + order, ): - """Test that the REST API lists the expected paginated environments.""" - response = testclient.get("api/v1/environment/?sort_by=name") + """Test the REST API lists the paginated envs when sorting by name.""" + response = testclient.get(f"api/v1/environment/?sort_by=name&order={order}") response.raise_for_status() - r = schema.APIListEnvironment.parse_obj(response.json()) + model = schema.APIListEnvironment.model_validate(response.json()) + assert model.status == schema.APIStatus.OK - assert r.status == schema.APIStatus.OK + env_names = [env.name for env in model.data] + assert sorted(env_names, reverse=order == "desc") == env_names + + +@pytest.mark.parametrize( + "order", + [ + "asc", + "desc", + ], +) +def test_api_list_environments_by_namespace( + conda_store_server, + testclient, + seed_conda_store_big, + authenticate, + order, +): + """Test the REST API lists the paginated envs when sorting by namespace.""" + response = testclient.get(f"api/v1/environment/?sort_by=namespace&order={order}") + response.raise_for_status() + + model = schema.APIListEnvironment.model_validate(response.json()) + assert model.status == schema.APIStatus.OK + + env_names = [env.namespace.name for env in model.data] + assert sorted(env_names, reverse=order == "desc") == env_names + + +@pytest.mark.parametrize( + "order", + [ + "asc", + "desc", + ], +) +def test_api_list_environments_by_namespace_name( + conda_store_server, + testclient, + seed_conda_store_big, + authenticate, + order, +): + """Test the REST API lists the paginated envs when sorting by namespace.""" + response = testclient.get( + f"api/v1/environment/?sort_by=namespace,name&order={order}" + ) + response.raise_for_status() + + model = schema.APIListEnvironment.model_validate(response.json()) + assert model.status == schema.APIStatus.OK + + env_names = [env.namespace.name for env in model.data] + assert sorted(env_names, reverse=order == "desc") == env_names diff --git a/conda-store-server/tests/conftest.py b/conda-store-server/tests/conftest.py index f1cebe23b..8cbaa1cea 100644 --- a/conda-store-server/tests/conftest.py +++ b/conda-store-server/tests/conftest.py @@ -5,6 +5,8 @@ import datetime import json import pathlib +import random +import string import sys import typing import uuid @@ -165,6 +167,50 @@ def seed_conda_store(db, conda_store): return db +@pytest.fixture +def seed_conda_store_big(db, conda_store): + default = {} + namespace1 = {} + namespace2 = {} + for i in range(50): + name = "".join(random.choices(string.ascii_letters, k=10)) + default[name] = schema.CondaSpecification( + name=name, channels=["defaults"], dependencies=["numpy"] + ) + + name = "".join(random.choices(string.ascii_letters, k=11)) + namespace1[name] = schema.CondaSpecification( + name=name, + channels=["defaults"], + dependencies=["flask"], + ) + + name = "".join(random.choices(string.ascii_letters, k=12)) + namespace2[name] = schema.CondaSpecification( + name=name, + channels=["defaults"], + dependencies=["flask"], + ) + + _seed_conda_store( + db, + conda_store, + { + "default": default, + "namespace1": namespace1, + "namespace2": namespace2, + }, + ) + + # for testing purposes make build 4 complete + build = api.get_build(db, build_id=4) + build.started_on = datetime.datetime.utcnow() + build.ended_on = datetime.datetime.utcnow() + build.status = schema.BuildStatus.COMPLETED + db.commit() + return db + + @pytest.fixture def conda_store(conda_store_config): _conda_store = app.CondaStore(config=conda_store_config)