Skip to content

Commit b0c4a48

Browse files
authored
feat: add async client (#139)
Fixes #39
1 parent 88ed08d commit b0c4a48

File tree

12 files changed

+433
-96
lines changed

12 files changed

+433
-96
lines changed

.pre-commit-config.yaml

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ ci:
77
autofix_prs: false
88
autoupdate_commit_msg: 'build: update pre-commit hooks'
99
autoupdate_schedule: monthly
10-
skip: [licensecheck] # does not run on pre-commit.ci, due to sqlite error, runs locally
10+
skip: [licensecheck, unasyncd] # does not run on pre-commit.ci, due to sqlite error, runs locally
11+
# remove unasyncd, when rev is set to v0.7.3
1112

1213
# Exclude "cassette" files: auto-generated by vcr.py
1314
# Exclude changelog: auto-generated by python-semantic-release
@@ -72,6 +73,13 @@ repos:
7273
additional_dependencies:
7374
- mdformat-mkdocs[recommended]>=v2.0.7
7475

76+
- repo: https://github.com/provinzkraut/unasyncd
77+
rev: main # change to v0.7.3, when it is released
78+
hooks:
79+
- id: unasyncd
80+
additional_dependencies:
81+
- ruff>=0.5.0
82+
7583
- repo: https://github.com/astral-sh/ruff-pre-commit
7684
rev: 1dc9eb131c2ea4816c708e4d85820d2cc8542683 # frozen: v0.5.0
7785
hooks:

docs/src/api.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,14 @@
1212

1313
::: re3data._client.RepositoryManager
1414

15+
## `AsyncClient`
16+
17+
::: re3data.AsyncClient
18+
19+
## `AsyncRepositoryManager`
20+
21+
::: re3data._client.AsyncRepositoryManager
22+
1523
## `Response`
1624

1725
::: re3data.Response

pyproject.toml

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ optional-dependencies.docs = [
6262
]
6363
optional-dependencies.test = [
6464
"pytest~=8.2",
65+
"pytest-asyncio~=0.23",
6566
"pytest-cov~=5.0",
6667
"pytest-mock~=3.14",
6768
"pytest-randomly~=3.15",
@@ -194,21 +195,17 @@ lint.ignore = [
194195
lint.per-file-ignores."src/re3data/__about__.py" = [
195196
"D100", # undocumented-public-module
196197
]
197-
lint.per-file-ignores."src/re3data/_resources/*" = [
198+
lint.per-file-ignores."src/re3data/_resources/*.py" = [
198199
"D101", # undocumented-public-class
199200
"D106", # undocumented-public-nested-class
200201
"D205", # blank-line-after-summary
201202
"D415", # ends-in-punctuation
202203
"TCH002", # typing-only-third-party-import
203204
]
204-
lint.per-file-ignores."tests/*" = [
205-
"D100", # undocumented-public-module
206-
"D103", # undocumented-public-function
205+
lint.per-file-ignores."tests/**.py" = [
206+
"D", # pydocstyle
207207
"PLR2004", # magic-value-comparison
208208
]
209-
lint.unfixable = [
210-
"F401", # unused-import
211-
]
212209
lint.isort.known-first-party = [
213210
"re3data",
214211
]
@@ -232,6 +229,7 @@ addopts = [
232229
"--strict-markers",
233230
"--strict-config",
234231
]
232+
asyncio_mode = "auto"
235233
filterwarnings = [
236234
"error",
237235
]
@@ -253,7 +251,6 @@ omit = [
253251
[tool.coverage.report]
254252
exclude_also = [
255253
"if TYPE_CHECKING:",
256-
"@abstractmethod",
257254
"@overload",
258255
]
259256
fail_under = 90
@@ -278,6 +275,23 @@ warn_unreachable = true
278275
using = "PEP631"
279276
format = "ansi"
280277

278+
[tool.unasyncd] # Ref: https://github.com/provinzkraut/unasyncd?tab=readme-ov-file#configuration
279+
add_editors_note = true
280+
ruff_fix = true
281+
transform_docstrings = true
282+
283+
[tool.unasyncd.files]
284+
"src/re3data/_client/_async.py" = "src/re3data/_client/_sync.py"
285+
"tests/integration/test_async_client.py" = "tests/integration/test_client.py"
286+
287+
[tool.unasyncd.add_replacements]
288+
"AsyncClient" = "Client"
289+
"re3data.AsyncClient" = "re3data.Client"
290+
"async_client" = "client"
291+
"async_log_response" = "log_response"
292+
"httpx.AsyncClient" = "httpx.Client"
293+
"AsyncRepositoryManager" = "RepositoryManager"
294+
281295
[tool.semantic_release] # Ref: https://python-semantic-release.readthedocs.io/en/latest/configuration.html#settings
282296
commit_author = "github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>"
283297
commit_message = "chore: release {version}\n\nAutomatically generated by python-semantic-release [skip ci]"

src/re3data/__init__.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@
55
"""python-re3data."""
66

77
from re3data.__about__ import __version__
8-
from re3data._client import Client, ReturnType
8+
from re3data._client import AsyncClient, Client, ReturnType
99
from re3data._exceptions import Re3dataError, RepositoryNotFoundError
1010
from re3data._resources import Re3Data, Repository, RepositoryList, RepositorySummary
1111
from re3data._response import Response
1212

13-
__all__ = [
13+
__all__ = (
14+
"AsyncClient",
1415
"Client",
1516
"Re3Data",
1617
"Re3dataError",
@@ -21,7 +22,7 @@
2122
"Response",
2223
"ReturnType",
2324
"__version__",
24-
]
25+
)
2526

2627
_client = Client()
2728
repositories = _client.repositories

src/re3data/_client/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from re3data._client._async import AsyncClient, AsyncRepositoryManager
2+
from re3data._client._sync import Client, RepositoryManager
3+
from re3data._client.base import BaseClient, ReturnType
4+
5+
__all__ = (
6+
"AsyncClient",
7+
"AsyncRepositoryManager",
8+
"BaseClient",
9+
"Client",
10+
"RepositoryManager",
11+
"ReturnType",
12+
)

src/re3data/_client/_async.py

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
# SPDX-FileCopyrightText: 2024 Heinz-Alexander Fütterer
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
from __future__ import annotations
6+
7+
import logging
8+
from typing import TYPE_CHECKING, Literal, overload
9+
10+
import httpx
11+
12+
from re3data._client.base import BaseClient, Endpoint, ResourceType, ReturnType, is_valid_return_type
13+
from re3data._exceptions import RepositoryNotFoundError
14+
from re3data._response import Response, _build_response, _parse_repositories_response, _parse_repository_response
15+
16+
if TYPE_CHECKING:
17+
from re3data._resources import Repository, RepositorySummary
18+
19+
logger = logging.getLogger(__name__)
20+
21+
22+
async def async_log_response(response: httpx.Response) -> None:
23+
"""Log the details of an HTTP response.
24+
25+
This function logs the HTTP method, URL, and status code of the response for debugging purposes.
26+
It uses the 'debug' logging level to provide detailed diagnostic information.
27+
28+
Args:
29+
response: The response object received from an HTTP request.
30+
31+
Returns:
32+
None
33+
"""
34+
logger.debug(
35+
"[http] Response: %s %s - Status %s", response.request.method, response.request.url, response.status_code
36+
)
37+
38+
39+
@overload
40+
def _dispatch_return_type(
41+
response: Response, resource_type: Literal[ResourceType.REPOSITORY], return_type: ReturnType
42+
) -> Repository | Response | str: ...
43+
@overload
44+
def _dispatch_return_type(
45+
response: Response, resource_type: Literal[ResourceType.REPOSITORY_LIST], return_type: ReturnType
46+
) -> list[RepositorySummary] | Response | str: ...
47+
48+
49+
def _dispatch_return_type(
50+
response: Response, resource_type: ResourceType, return_type: ReturnType
51+
) -> Repository | list[RepositorySummary] | Response | str:
52+
"""Dispatch the response to the correct return type based on the provided return type and resource type.
53+
54+
Args:
55+
response: The response object.
56+
resource_type: The type of resource being processed.
57+
return_type: The desired return type for the API resource.
58+
59+
Returns:
60+
Depending on the return_type and resource_type, this can be a Repository object, a list of RepositorySummary
61+
objects, an HTTP response, or the original XML.
62+
"""
63+
if return_type == ReturnType.DATACLASS:
64+
if resource_type == ResourceType.REPOSITORY_LIST:
65+
return _parse_repositories_response(response)
66+
if resource_type == ResourceType.REPOSITORY:
67+
return _parse_repository_response(response)
68+
if return_type == ReturnType.XML:
69+
return response.text
70+
return response
71+
72+
73+
class AsyncRepositoryManager:
74+
"""A manager for interacting with repositories in the re3data API.
75+
76+
Attributes:
77+
_client: The client used to make requests.
78+
"""
79+
80+
def __init__(self, client: AsyncClient) -> None:
81+
self._client = client
82+
83+
async def list(self, return_type: ReturnType = ReturnType.DATACLASS) -> list[RepositorySummary] | Response | str:
84+
"""List the metadata of all repositories in the re3data API.
85+
86+
Args:
87+
return_type: The desired return type for the API resource. Defaults to `ReturnType.DATACLASS`.
88+
89+
Returns:
90+
Depending on the `return_type`, this can be a list of RepositorySummary objects, an HTTP response,
91+
or the original XML.
92+
93+
Raises:
94+
ValueError: If an invalid `return_type` is provided.
95+
httpx.HTTPStatusError: If the server returned an error status code >= 500.
96+
"""
97+
is_valid_return_type(return_type)
98+
response = await self._client._request(Endpoint.REPOSITORY_LIST.value)
99+
return _dispatch_return_type(response, ResourceType.REPOSITORY_LIST, return_type)
100+
101+
async def get(
102+
self, repository_id: str, return_type: ReturnType = ReturnType.DATACLASS
103+
) -> Repository | Response | str:
104+
"""Get the metadata of a specific repository.
105+
106+
Args:
107+
repository_id: The identifier of the repository to retrieve.
108+
return_type: The desired return type for the API resource. Defaults to `ReturnType.DATACLASS`.
109+
110+
Returns:
111+
Depending on the `return_type`, this can be a Repository object, an HTTP response, or the original XML.
112+
113+
Raises:
114+
ValueError: If an invalid `return_type` is provided.
115+
httpx.HTTPStatusError: If the server returned an error status code >= 500.
116+
RepositoryNotFoundError: If no repository with the given ID is found.
117+
"""
118+
is_valid_return_type(return_type)
119+
response = await self._client._request(Endpoint.REPOSITORY.value.format(repository_id=repository_id))
120+
if response.status_code == httpx.codes.NOT_FOUND:
121+
raise RepositoryNotFoundError(f"No repository with id '{repository_id}' available at {response.url}.")
122+
return _dispatch_return_type(response, ResourceType.REPOSITORY, return_type)
123+
124+
125+
class AsyncClient(BaseClient):
126+
"""A client that interacts with the re3data API.
127+
128+
Attributes:
129+
_client: The underlying HTTP client.
130+
_repository_manager: The repository manager to retrieve metadata from the repositories endpoints.
131+
132+
Examples:
133+
>>> async_client = AsyncClient():
134+
>>> response = await async_client.repositories.list()
135+
>>> response
136+
[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'))]
137+
... (remaining repositories truncated)
138+
"""
139+
140+
_client: httpx.AsyncClient
141+
142+
def __init__(self) -> None:
143+
super().__init__(httpx.AsyncClient)
144+
self._client.event_hooks["response"] = [async_log_response]
145+
self._repository_manager: AsyncRepositoryManager = AsyncRepositoryManager(self)
146+
147+
async def _request(self, path: str) -> Response:
148+
"""Send a HTTP GET request to the specified API endpoint.
149+
150+
Args:
151+
path: The path to send the request to.
152+
153+
Returns:
154+
The response object from the HTTP request.
155+
156+
Raises:
157+
httpx.HTTPStatusError: If the server returned an error status code >= 500.
158+
RepositoryNotFoundError: If the `repository_id` is not found.
159+
"""
160+
http_response = await self._client.get(path)
161+
if http_response.is_server_error:
162+
http_response.raise_for_status()
163+
return _build_response(http_response)
164+
165+
@property
166+
def repositories(self) -> AsyncRepositoryManager:
167+
"""Get the repository manager for this client.
168+
169+
Returns:
170+
The repository manager.
171+
"""
172+
return self._repository_manager

0 commit comments

Comments
 (0)