Skip to content

Commit 713d4d1

Browse files
authored
feat: add repository filtering based on query string (#152)
Fixes #131
1 parent c1205d8 commit 713d4d1

File tree

8 files changed

+142
-12
lines changed

8 files changed

+142
-12
lines changed

src/re3data/_cli.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import logging
88
import sys
99
import typing
10+
from typing import Annotated, Optional
1011

1112
from rich.console import Console
1213

@@ -70,9 +71,17 @@ def callback(
7071

7172

7273
@repositories_app.command("list")
73-
def list_repositories(return_type: ReturnType = ReturnType.DATACLASS) -> None:
74+
def list_repositories(
75+
query: Annotated[
76+
Optional[str], # noqa: UP007
77+
typer.Option(
78+
help="A query to filter the results. If provided, only repositories matching the query will be returned."
79+
),
80+
] = None,
81+
return_type: ReturnType = ReturnType.DATACLASS,
82+
) -> None:
7483
"""List the metadata of all repositories in the re3data API."""
75-
response = re3data.repositories.list(return_type)
84+
response = re3data.repositories.list(query, return_type)
7685
console.print(response)
7786

7887

src/re3data/_client/_async.py

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,14 @@
99

1010
import httpx
1111

12-
from re3data._client.base import BaseClient, Endpoint, ResourceType, ReturnType, is_valid_return_type
12+
from re3data._client.base import (
13+
BaseClient,
14+
Endpoint,
15+
ResourceType,
16+
ReturnType,
17+
_build_query_params,
18+
is_valid_return_type,
19+
)
1320
from re3data._exceptions import RepositoryNotFoundError
1421
from re3data._response import Response, _build_response, _parse_repositories_response, _parse_repository_response
1522

@@ -80,10 +87,14 @@ class AsyncRepositoryManager:
8087
def __init__(self, client: AsyncClient) -> None:
8188
self._client = client
8289

83-
async def list(self, return_type: ReturnType = ReturnType.DATACLASS) -> list[RepositorySummary] | Response | str:
90+
async def list(
91+
self, query: str | None = None, return_type: ReturnType = ReturnType.DATACLASS
92+
) -> list[RepositorySummary] | Response | str:
8493
"""List the metadata of all repositories in the re3data API.
8594
8695
Args:
96+
query: A query string to filter the results. If provided, only repositories matching the query
97+
will be returned.
8798
return_type: The desired return type for the API resource. Defaults to `ReturnType.DATACLASS`.
8899
89100
Returns:
@@ -95,7 +106,8 @@ async def list(self, return_type: ReturnType = ReturnType.DATACLASS) -> list[Rep
95106
httpx.HTTPStatusError: If the server returned an error status code >= 500.
96107
"""
97108
is_valid_return_type(return_type)
98-
response = await self._client._request(Endpoint.REPOSITORY_LIST.value)
109+
query_params = _build_query_params(query)
110+
response = await self._client._request(Endpoint.REPOSITORY_LIST.value, query_params)
99111
return _dispatch_return_type(response, ResourceType.REPOSITORY_LIST, return_type)
100112

101113
async def get(
@@ -135,6 +147,9 @@ class AsyncClient(BaseClient):
135147
>>> response
136148
[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'))]
137149
... (remaining repositories truncated)
150+
>>> response = await async_client.repositories.list(query="biosharing")
151+
>>> response
152+
[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'))]
138153
"""
139154

140155
_client: httpx.AsyncClient
@@ -144,11 +159,13 @@ def __init__(self) -> None:
144159
self._client.event_hooks["response"] = [async_log_response]
145160
self._repository_manager: AsyncRepositoryManager = AsyncRepositoryManager(self)
146161

147-
async def _request(self, path: str) -> Response:
162+
async def _request(self, path: str, query_params: dict[str, str] | None = None) -> Response:
148163
"""Send a HTTP GET request to the specified API endpoint.
149164
150165
Args:
151166
path: The path to send the request to.
167+
query_params: Optional URL query parameters to be sent with the HTTP GET request. This dictionary
168+
contains key-value pairs that will be added as query parameters to the API endpoint specified by path.
152169
153170
Returns:
154171
The response object from the HTTP request.
@@ -157,7 +174,7 @@ async def _request(self, path: str) -> Response:
157174
httpx.HTTPStatusError: If the server returned an error status code >= 500.
158175
RepositoryNotFoundError: If the `repository_id` is not found.
159176
"""
160-
http_response = await self._client.get(path)
177+
http_response = await self._client.get(path, params=query_params)
161178
if http_response.is_server_error:
162179
http_response.raise_for_status()
163180
return _build_response(http_response)

src/re3data/_client/_sync.py

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,14 @@
1111

1212
import httpx
1313

14-
from re3data._client.base import BaseClient, Endpoint, ResourceType, ReturnType, is_valid_return_type
14+
from re3data._client.base import (
15+
BaseClient,
16+
Endpoint,
17+
ResourceType,
18+
ReturnType,
19+
_build_query_params,
20+
is_valid_return_type,
21+
)
1522
from re3data._exceptions import RepositoryNotFoundError
1623
from re3data._response import Response, _build_response, _parse_repositories_response, _parse_repository_response
1724

@@ -82,10 +89,14 @@ class RepositoryManager:
8289
def __init__(self, client: Client) -> None:
8390
self._client = client
8491

85-
def list(self, return_type: ReturnType = ReturnType.DATACLASS) -> list[RepositorySummary] | Response | str:
92+
def list(
93+
self, query: str | None = None, return_type: ReturnType = ReturnType.DATACLASS
94+
) -> list[RepositorySummary] | Response | str:
8695
"""List the metadata of all repositories in the re3data API.
8796
8897
Args:
98+
query: A query string to filter the results. If provided, only repositories matching the query
99+
will be returned.
89100
return_type: The desired return type for the API resource. Defaults to `ReturnType.DATACLASS`.
90101
91102
Returns:
@@ -97,7 +108,8 @@ def list(self, return_type: ReturnType = ReturnType.DATACLASS) -> list[Repositor
97108
httpx.HTTPStatusError: If the server returned an error status code >= 500.
98109
"""
99110
is_valid_return_type(return_type)
100-
response = self._client._request(Endpoint.REPOSITORY_LIST.value)
111+
query_params = _build_query_params(query)
112+
response = self._client._request(Endpoint.REPOSITORY_LIST.value, query_params)
101113
return _dispatch_return_type(response, ResourceType.REPOSITORY_LIST, return_type)
102114

103115
def get(self, repository_id: str, return_type: ReturnType = ReturnType.DATACLASS) -> Repository | Response | str:
@@ -135,6 +147,9 @@ class Client(BaseClient):
135147
>>> response
136148
[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'))]
137149
... (remaining repositories truncated)
150+
>>> response = client.repositories.list(query="biosharing")
151+
>>> response
152+
[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'))]
138153
"""
139154

140155
_client: httpx.Client
@@ -144,11 +159,13 @@ def __init__(self) -> None:
144159
self._client.event_hooks["response"] = [log_response]
145160
self._repository_manager: RepositoryManager = RepositoryManager(self)
146161

147-
def _request(self, path: str) -> Response:
162+
def _request(self, path: str, query_params: dict[str, str] | None = None) -> Response:
148163
"""Send a HTTP GET request to the specified API endpoint.
149164
150165
Args:
151166
path: The path to send the request to.
167+
query_params: Optional URL query parameters to be sent with the HTTP GET request. This dictionary
168+
contains key-value pairs that will be added as query parameters to the API endpoint specified by path.
152169
153170
Returns:
154171
The response object from the HTTP request.
@@ -157,7 +174,7 @@ def _request(self, path: str) -> Response:
157174
httpx.HTTPStatusError: If the server returned an error status code >= 500.
158175
RepositoryNotFoundError: If the `repository_id` is not found.
159176
"""
160-
http_response = self._client.get(path)
177+
http_response = self._client.get(path, params=query_params)
161178
if http_response.is_server_error:
162179
http_response.raise_for_status()
163180
return _build_response(http_response)

src/re3data/_client/base.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,22 @@ def is_valid_return_type(return_type: Any) -> None:
5454
raise ValueError(f"Invalid value for `return_type`: {return_type} is not one of {allowed_types}.")
5555

5656

57+
def _build_query_params(query: str | None = None) -> dict[str, str]:
58+
"""Build query parameters based on the input query string.
59+
60+
Args:
61+
query: The input query string. Defaults to None.
62+
63+
Returns:
64+
A dictionary containing the query parameter(s). If no query is provided,
65+
the function returns an empty dictionary.
66+
"""
67+
query_params = {}
68+
if query:
69+
query_params["query"] = query
70+
return query_params
71+
72+
5773
class BaseClient:
5874
"""An abstract base class for clients that interact with the re3data API."""
5975

tests/conftest.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,31 @@ def mock_repository_list_route(respx_mock: MockRouter, repository_list_xml: str)
6969
)
7070

7171

72+
@pytest.fixture()
73+
def mock_repository_list_query_route(respx_mock: MockRouter) -> Route:
74+
query_result_xml = """<?xml version="1.0" encoding="UTF-8"?>
75+
<list>
76+
<repository>
77+
<id>r3d100010142</id>
78+
<doi>https://doi.org/10.17616/R3WS3X</doi>
79+
<name>FAIRsharing</name>
80+
<link href="https://www.re3data.org/api/beta/repository/r3d100010142" rel="self" />
81+
</repository>
82+
</list>
83+
"""
84+
return respx_mock.get("https://www.re3data.org/api/beta/repositories?query=biosharing").mock(
85+
return_value=httpx.Response(httpx.codes.OK, text=query_result_xml)
86+
)
87+
88+
89+
@pytest.fixture()
90+
def mock_repository_list_query_empty_list_route(respx_mock: MockRouter) -> Route:
91+
query_result_xml = '<?xml version="1.0" encoding="UTF-8"?><list></list>'
92+
return respx_mock.get("https://www.re3data.org/api/beta/repositories?query=XXX").mock(
93+
return_value=httpx.Response(httpx.codes.OK, text=query_result_xml)
94+
)
95+
96+
7297
REPOSITORY_GET_XML: str = """<?xml version="1.0" encoding="utf-8"?>
7398
<!--re3data.org Schema for the Description of Research
7499
Data Repositories. Version 2.2, December 2014. doi:10.2312/re3.006-->

tests/integration/test_async_client.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,24 @@ async def test_client_list_repositories_response(async_client: AsyncClient, mock
5555
assert response.status_code == httpx.codes.OK
5656

5757

58+
async def test_client_list_repositories_query_string(
59+
async_client: AsyncClient, mock_repository_list_query_route: Route
60+
) -> None:
61+
response = await async_client.repositories.list(query="biosharing")
62+
assert isinstance(response, list)
63+
repository = response[0]
64+
assert isinstance(repository, RepositorySummary)
65+
assert repository.id == "r3d100010142"
66+
67+
68+
async def test_client_list_repositories_query_string_returns_empty_list(
69+
async_client: AsyncClient, mock_repository_list_query_empty_list_route: Route
70+
) -> None:
71+
response = await async_client.repositories.list(query="XXX")
72+
assert isinstance(response, list)
73+
assert response == []
74+
75+
5876
async def test_client_get_single_repository_default_return_type(
5977
async_client: AsyncClient, mock_repository_get_route: Route, zenodo_id: str
6078
) -> None:

tests/integration/test_cli.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,18 @@ def test_repository_list_invalid_return_type(mock_repository_list_route: Route)
102102
assert "Invalid value for '--return-type': 'json'" in result.output
103103

104104

105+
def test_repository_list_query(mock_repository_list_query_route: Route) -> None:
106+
result = runner.invoke(app, ["repository", "list", "--query", "biosharing"])
107+
assert result.exit_code == 0
108+
assert "id='r3d100010142'" in result.output
109+
assert "doi='https://doi.org/10.17616/R3WS3X'" in result.output
110+
111+
112+
def test_repository_list_query_returns_empty_list(mock_repository_list_query_empty_list_route: Route) -> None:
113+
result = runner.invoke(app, ["repository", "list", "--query", "XXX"])
114+
assert result.exit_code == 0
115+
116+
105117
def test_repository_get_without_repository_id(mock_repository_list_route: Route) -> None:
106118
result = runner.invoke(app, ["repository", "get"])
107119
assert result.exit_code == 2

tests/integration/test_client.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,22 @@ def test_client_list_repositories_response(client: Client, mock_repository_list_
5555
assert response.status_code == httpx.codes.OK
5656

5757

58+
def test_client_list_repositories_query_string(client: Client, mock_repository_list_query_route: Route) -> None:
59+
response = client.repositories.list(query="biosharing")
60+
assert isinstance(response, list)
61+
repository = response[0]
62+
assert isinstance(repository, RepositorySummary)
63+
assert repository.id == "r3d100010142"
64+
65+
66+
def test_client_list_repositories_query_string_returns_empty_list(
67+
client: Client, mock_repository_list_query_empty_list_route: Route
68+
) -> None:
69+
response = client.repositories.list(query="XXX")
70+
assert isinstance(response, list)
71+
assert response == []
72+
73+
5874
def test_client_get_single_repository_default_return_type(
5975
client: Client, mock_repository_get_route: Route, zenodo_id: str
6076
) -> None:

0 commit comments

Comments
 (0)