Skip to content

Commit

Permalink
feat: add repository filtering based on query string (#152)
Browse files Browse the repository at this point in the history
Fixes #131
  • Loading branch information
afuetterer authored Jul 3, 2024
1 parent c1205d8 commit 713d4d1
Show file tree
Hide file tree
Showing 8 changed files with 142 additions and 12 deletions.
13 changes: 11 additions & 2 deletions src/re3data/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import logging
import sys
import typing
from typing import Annotated, Optional

from rich.console import Console

Expand Down Expand Up @@ -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)


Expand Down
27 changes: 22 additions & 5 deletions src/re3data/_client/_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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)
Expand Down
27 changes: 22 additions & 5 deletions src/re3data/_client/_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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)
Expand Down
16 changes: 16 additions & 0 deletions src/re3data/_client/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
25 changes: 25 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = """<?xml version="1.0" encoding="UTF-8"?>
<list>
<repository>
<id>r3d100010142</id>
<doi>https://doi.org/10.17616/R3WS3X</doi>
<name>FAIRsharing</name>
<link href="https://www.re3data.org/api/beta/repository/r3d100010142" rel="self" />
</repository>
</list>
"""
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 = '<?xml version="1.0" encoding="UTF-8"?><list></list>'
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 = """<?xml version="1.0" encoding="utf-8"?>
<!--re3data.org Schema for the Description of Research
Data Repositories. Version 2.2, December 2014. doi:10.2312/re3.006-->
Expand Down
18 changes: 18 additions & 0 deletions tests/integration/test_async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
12 changes: 12 additions & 0 deletions tests/integration/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions tests/integration/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down

0 comments on commit 713d4d1

Please sign in to comment.