From 713d4d1cd581426a95fd8d6a84f5fa4f4fff1564 Mon Sep 17 00:00:00 2001 From: Heinz-Alexander Fuetterer <35225576+afuetterer@users.noreply.github.com> Date: Wed, 3 Jul 2024 12:44:22 +0000 Subject: [PATCH] feat: add repository filtering based on query string (#152) Fixes #131 --- src/re3data/_cli.py | 13 +++++++++++-- src/re3data/_client/_async.py | 27 +++++++++++++++++++++----- src/re3data/_client/_sync.py | 27 +++++++++++++++++++++----- src/re3data/_client/base.py | 16 +++++++++++++++ tests/conftest.py | 25 ++++++++++++++++++++++++ tests/integration/test_async_client.py | 18 +++++++++++++++++ tests/integration/test_cli.py | 12 ++++++++++++ tests/integration/test_client.py | 16 +++++++++++++++ 8 files changed, 142 insertions(+), 12 deletions(-) diff --git a/src/re3data/_cli.py b/src/re3data/_cli.py index 84d9a7b..0adcdd8 100644 --- a/src/re3data/_cli.py +++ b/src/re3data/_cli.py @@ -7,6 +7,7 @@ import logging import sys import typing +from typing import Annotated, Optional from rich.console import Console @@ -70,9 +71,17 @@ def callback( @repositories_app.command("list") -def list_repositories(return_type: ReturnType = ReturnType.DATACLASS) -> None: +def list_repositories( + query: Annotated[ + Optional[str], # noqa: UP007 + typer.Option( + help="A query to filter the results. If provided, only repositories matching the query will be returned." + ), + ] = None, + return_type: ReturnType = ReturnType.DATACLASS, +) -> None: """List the metadata of all repositories in the re3data API.""" - response = re3data.repositories.list(return_type) + response = re3data.repositories.list(query, return_type) console.print(response) diff --git a/src/re3data/_client/_async.py b/src/re3data/_client/_async.py index a1d0c98..9d41395 100644 --- a/src/re3data/_client/_async.py +++ b/src/re3data/_client/_async.py @@ -9,7 +9,14 @@ import httpx -from re3data._client.base import BaseClient, Endpoint, ResourceType, ReturnType, is_valid_return_type +from re3data._client.base import ( + BaseClient, + Endpoint, + ResourceType, + ReturnType, + _build_query_params, + is_valid_return_type, +) from re3data._exceptions import RepositoryNotFoundError from re3data._response import Response, _build_response, _parse_repositories_response, _parse_repository_response @@ -80,10 +87,14 @@ class AsyncRepositoryManager: def __init__(self, client: AsyncClient) -> None: self._client = client - async def list(self, return_type: ReturnType = ReturnType.DATACLASS) -> list[RepositorySummary] | Response | str: + async def list( + self, query: str | None = None, return_type: ReturnType = ReturnType.DATACLASS + ) -> list[RepositorySummary] | Response | str: """List the metadata of all repositories in the re3data API. Args: + query: A query string to filter the results. If provided, only repositories matching the query + will be returned. return_type: The desired return type for the API resource. Defaults to `ReturnType.DATACLASS`. Returns: @@ -95,7 +106,8 @@ async def list(self, return_type: ReturnType = ReturnType.DATACLASS) -> list[Rep httpx.HTTPStatusError: If the server returned an error status code >= 500. """ is_valid_return_type(return_type) - response = await self._client._request(Endpoint.REPOSITORY_LIST.value) + query_params = _build_query_params(query) + response = await self._client._request(Endpoint.REPOSITORY_LIST.value, query_params) return _dispatch_return_type(response, ResourceType.REPOSITORY_LIST, return_type) async def get( @@ -135,6 +147,9 @@ class AsyncClient(BaseClient): >>> response [RepositorySummary(id='r3d100010468', doi='https://doi.org/10.17616/R3QP53', name='Zenodo', link=Link(href='https://www.re3data.org/api/beta/repository/r3d100010468', rel='self'))] ... (remaining repositories truncated) + >>> response = await async_client.repositories.list(query="biosharing") + >>> response + [RepositorySummary(id='r3d100010142', doi='https://doi.org/10.17616/R3WS3X', name='FAIRsharing', link=Link(href='https://www.re3data.org/api/beta/repository/r3d100010142', rel='self'))] """ _client: httpx.AsyncClient @@ -144,11 +159,13 @@ def __init__(self) -> None: self._client.event_hooks["response"] = [async_log_response] self._repository_manager: AsyncRepositoryManager = AsyncRepositoryManager(self) - async def _request(self, path: str) -> Response: + async def _request(self, path: str, query_params: dict[str, str] | None = None) -> Response: """Send a HTTP GET request to the specified API endpoint. Args: path: The path to send the request to. + query_params: Optional URL query parameters to be sent with the HTTP GET request. This dictionary + contains key-value pairs that will be added as query parameters to the API endpoint specified by path. Returns: The response object from the HTTP request. @@ -157,7 +174,7 @@ async def _request(self, path: str) -> Response: httpx.HTTPStatusError: If the server returned an error status code >= 500. RepositoryNotFoundError: If the `repository_id` is not found. """ - http_response = await self._client.get(path) + http_response = await self._client.get(path, params=query_params) if http_response.is_server_error: http_response.raise_for_status() return _build_response(http_response) diff --git a/src/re3data/_client/_sync.py b/src/re3data/_client/_sync.py index d5a390a..5b9aba6 100644 --- a/src/re3data/_client/_sync.py +++ b/src/re3data/_client/_sync.py @@ -11,7 +11,14 @@ import httpx -from re3data._client.base import BaseClient, Endpoint, ResourceType, ReturnType, is_valid_return_type +from re3data._client.base import ( + BaseClient, + Endpoint, + ResourceType, + ReturnType, + _build_query_params, + is_valid_return_type, +) from re3data._exceptions import RepositoryNotFoundError from re3data._response import Response, _build_response, _parse_repositories_response, _parse_repository_response @@ -82,10 +89,14 @@ class RepositoryManager: def __init__(self, client: Client) -> None: self._client = client - def list(self, return_type: ReturnType = ReturnType.DATACLASS) -> list[RepositorySummary] | Response | str: + def list( + self, query: str | None = None, return_type: ReturnType = ReturnType.DATACLASS + ) -> list[RepositorySummary] | Response | str: """List the metadata of all repositories in the re3data API. Args: + query: A query string to filter the results. If provided, only repositories matching the query + will be returned. return_type: The desired return type for the API resource. Defaults to `ReturnType.DATACLASS`. Returns: @@ -97,7 +108,8 @@ def list(self, return_type: ReturnType = ReturnType.DATACLASS) -> list[Repositor httpx.HTTPStatusError: If the server returned an error status code >= 500. """ is_valid_return_type(return_type) - response = self._client._request(Endpoint.REPOSITORY_LIST.value) + query_params = _build_query_params(query) + response = self._client._request(Endpoint.REPOSITORY_LIST.value, query_params) return _dispatch_return_type(response, ResourceType.REPOSITORY_LIST, return_type) def get(self, repository_id: str, return_type: ReturnType = ReturnType.DATACLASS) -> Repository | Response | str: @@ -135,6 +147,9 @@ class Client(BaseClient): >>> response [RepositorySummary(id='r3d100010468', doi='https://doi.org/10.17616/R3QP53', name='Zenodo', link=Link(href='https://www.re3data.org/api/beta/repository/r3d100010468', rel='self'))] ... (remaining repositories truncated) + >>> response = client.repositories.list(query="biosharing") + >>> response + [RepositorySummary(id='r3d100010142', doi='https://doi.org/10.17616/R3WS3X', name='FAIRsharing', link=Link(href='https://www.re3data.org/api/beta/repository/r3d100010142', rel='self'))] """ _client: httpx.Client @@ -144,11 +159,13 @@ def __init__(self) -> None: self._client.event_hooks["response"] = [log_response] self._repository_manager: RepositoryManager = RepositoryManager(self) - def _request(self, path: str) -> Response: + def _request(self, path: str, query_params: dict[str, str] | None = None) -> Response: """Send a HTTP GET request to the specified API endpoint. Args: path: The path to send the request to. + query_params: Optional URL query parameters to be sent with the HTTP GET request. This dictionary + contains key-value pairs that will be added as query parameters to the API endpoint specified by path. Returns: The response object from the HTTP request. @@ -157,7 +174,7 @@ def _request(self, path: str) -> Response: httpx.HTTPStatusError: If the server returned an error status code >= 500. RepositoryNotFoundError: If the `repository_id` is not found. """ - http_response = self._client.get(path) + http_response = self._client.get(path, params=query_params) if http_response.is_server_error: http_response.raise_for_status() return _build_response(http_response) diff --git a/src/re3data/_client/base.py b/src/re3data/_client/base.py index c4ffc58..eaec962 100644 --- a/src/re3data/_client/base.py +++ b/src/re3data/_client/base.py @@ -54,6 +54,22 @@ def is_valid_return_type(return_type: Any) -> None: raise ValueError(f"Invalid value for `return_type`: {return_type} is not one of {allowed_types}.") +def _build_query_params(query: str | None = None) -> dict[str, str]: + """Build query parameters based on the input query string. + + Args: + query: The input query string. Defaults to None. + + Returns: + A dictionary containing the query parameter(s). If no query is provided, + the function returns an empty dictionary. + """ + query_params = {} + if query: + query_params["query"] = query + return query_params + + class BaseClient: """An abstract base class for clients that interact with the re3data API.""" diff --git a/tests/conftest.py b/tests/conftest.py index 4c0309f..1fe55a9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -69,6 +69,31 @@ def mock_repository_list_route(respx_mock: MockRouter, repository_list_xml: str) ) +@pytest.fixture() +def mock_repository_list_query_route(respx_mock: MockRouter) -> Route: + query_result_xml = """ + + + r3d100010142 + https://doi.org/10.17616/R3WS3X + FAIRsharing + + + + """ + return respx_mock.get("https://www.re3data.org/api/beta/repositories?query=biosharing").mock( + return_value=httpx.Response(httpx.codes.OK, text=query_result_xml) + ) + + +@pytest.fixture() +def mock_repository_list_query_empty_list_route(respx_mock: MockRouter) -> Route: + query_result_xml = '' + return respx_mock.get("https://www.re3data.org/api/beta/repositories?query=XXX").mock( + return_value=httpx.Response(httpx.codes.OK, text=query_result_xml) + ) + + REPOSITORY_GET_XML: str = """ diff --git a/tests/integration/test_async_client.py b/tests/integration/test_async_client.py index 187ed36..f7d6055 100644 --- a/tests/integration/test_async_client.py +++ b/tests/integration/test_async_client.py @@ -55,6 +55,24 @@ async def test_client_list_repositories_response(async_client: AsyncClient, mock assert response.status_code == httpx.codes.OK +async def test_client_list_repositories_query_string( + async_client: AsyncClient, mock_repository_list_query_route: Route +) -> None: + response = await async_client.repositories.list(query="biosharing") + assert isinstance(response, list) + repository = response[0] + assert isinstance(repository, RepositorySummary) + assert repository.id == "r3d100010142" + + +async def test_client_list_repositories_query_string_returns_empty_list( + async_client: AsyncClient, mock_repository_list_query_empty_list_route: Route +) -> None: + response = await async_client.repositories.list(query="XXX") + assert isinstance(response, list) + assert response == [] + + async def test_client_get_single_repository_default_return_type( async_client: AsyncClient, mock_repository_get_route: Route, zenodo_id: str ) -> None: diff --git a/tests/integration/test_cli.py b/tests/integration/test_cli.py index 8f67303..f8eaaa5 100644 --- a/tests/integration/test_cli.py +++ b/tests/integration/test_cli.py @@ -102,6 +102,18 @@ def test_repository_list_invalid_return_type(mock_repository_list_route: Route) assert "Invalid value for '--return-type': 'json'" in result.output +def test_repository_list_query(mock_repository_list_query_route: Route) -> None: + result = runner.invoke(app, ["repository", "list", "--query", "biosharing"]) + assert result.exit_code == 0 + assert "id='r3d100010142'" in result.output + assert "doi='https://doi.org/10.17616/R3WS3X'" in result.output + + +def test_repository_list_query_returns_empty_list(mock_repository_list_query_empty_list_route: Route) -> None: + result = runner.invoke(app, ["repository", "list", "--query", "XXX"]) + assert result.exit_code == 0 + + def test_repository_get_without_repository_id(mock_repository_list_route: Route) -> None: result = runner.invoke(app, ["repository", "get"]) assert result.exit_code == 2 diff --git a/tests/integration/test_client.py b/tests/integration/test_client.py index d1d0bd9..13c97da 100644 --- a/tests/integration/test_client.py +++ b/tests/integration/test_client.py @@ -55,6 +55,22 @@ def test_client_list_repositories_response(client: Client, mock_repository_list_ assert response.status_code == httpx.codes.OK +def test_client_list_repositories_query_string(client: Client, mock_repository_list_query_route: Route) -> None: + response = client.repositories.list(query="biosharing") + assert isinstance(response, list) + repository = response[0] + assert isinstance(repository, RepositorySummary) + assert repository.id == "r3d100010142" + + +def test_client_list_repositories_query_string_returns_empty_list( + client: Client, mock_repository_list_query_empty_list_route: Route +) -> None: + response = client.repositories.list(query="XXX") + assert isinstance(response, list) + assert response == [] + + def test_client_get_single_repository_default_return_type( client: Client, mock_repository_get_route: Route, zenodo_id: str ) -> None: