Skip to content

Commit

Permalink
feat: add async client (#139)
Browse files Browse the repository at this point in the history
Fixes #39
  • Loading branch information
afuetterer authored Jul 2, 2024
1 parent 88ed08d commit b0c4a48
Show file tree
Hide file tree
Showing 12 changed files with 433 additions and 96 deletions.
10 changes: 9 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ ci:
autofix_prs: false
autoupdate_commit_msg: 'build: update pre-commit hooks'
autoupdate_schedule: monthly
skip: [licensecheck] # does not run on pre-commit.ci, due to sqlite error, runs locally
skip: [licensecheck, unasyncd] # does not run on pre-commit.ci, due to sqlite error, runs locally
# remove unasyncd, when rev is set to v0.7.3

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

- repo: https://github.com/provinzkraut/unasyncd
rev: main # change to v0.7.3, when it is released
hooks:
- id: unasyncd
additional_dependencies:
- ruff>=0.5.0

- repo: https://github.com/astral-sh/ruff-pre-commit
rev: 1dc9eb131c2ea4816c708e4d85820d2cc8542683 # frozen: v0.5.0
hooks:
Expand Down
8 changes: 8 additions & 0 deletions docs/src/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@

::: re3data._client.RepositoryManager

## `AsyncClient`

::: re3data.AsyncClient

## `AsyncRepositoryManager`

::: re3data._client.AsyncRepositoryManager

## `Response`

::: re3data.Response
30 changes: 22 additions & 8 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ optional-dependencies.docs = [
]
optional-dependencies.test = [
"pytest~=8.2",
"pytest-asyncio~=0.23",
"pytest-cov~=5.0",
"pytest-mock~=3.14",
"pytest-randomly~=3.15",
Expand Down Expand Up @@ -194,21 +195,17 @@ lint.ignore = [
lint.per-file-ignores."src/re3data/__about__.py" = [
"D100", # undocumented-public-module
]
lint.per-file-ignores."src/re3data/_resources/*" = [
lint.per-file-ignores."src/re3data/_resources/*.py" = [
"D101", # undocumented-public-class
"D106", # undocumented-public-nested-class
"D205", # blank-line-after-summary
"D415", # ends-in-punctuation
"TCH002", # typing-only-third-party-import
]
lint.per-file-ignores."tests/*" = [
"D100", # undocumented-public-module
"D103", # undocumented-public-function
lint.per-file-ignores."tests/**.py" = [
"D", # pydocstyle
"PLR2004", # magic-value-comparison
]
lint.unfixable = [
"F401", # unused-import
]
lint.isort.known-first-party = [
"re3data",
]
Expand All @@ -232,6 +229,7 @@ addopts = [
"--strict-markers",
"--strict-config",
]
asyncio_mode = "auto"
filterwarnings = [
"error",
]
Expand All @@ -253,7 +251,6 @@ omit = [
[tool.coverage.report]
exclude_also = [
"if TYPE_CHECKING:",
"@abstractmethod",
"@overload",
]
fail_under = 90
Expand All @@ -278,6 +275,23 @@ warn_unreachable = true
using = "PEP631"
format = "ansi"

[tool.unasyncd] # Ref: https://github.com/provinzkraut/unasyncd?tab=readme-ov-file#configuration
add_editors_note = true
ruff_fix = true
transform_docstrings = true

[tool.unasyncd.files]
"src/re3data/_client/_async.py" = "src/re3data/_client/_sync.py"
"tests/integration/test_async_client.py" = "tests/integration/test_client.py"

[tool.unasyncd.add_replacements]
"AsyncClient" = "Client"
"re3data.AsyncClient" = "re3data.Client"
"async_client" = "client"
"async_log_response" = "log_response"
"httpx.AsyncClient" = "httpx.Client"
"AsyncRepositoryManager" = "RepositoryManager"

[tool.semantic_release] # Ref: https://python-semantic-release.readthedocs.io/en/latest/configuration.html#settings
commit_author = "github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>"
commit_message = "chore: release {version}\n\nAutomatically generated by python-semantic-release [skip ci]"
Expand Down
7 changes: 4 additions & 3 deletions src/re3data/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@
"""python-re3data."""

from re3data.__about__ import __version__
from re3data._client import Client, ReturnType
from re3data._client import AsyncClient, Client, ReturnType
from re3data._exceptions import Re3dataError, RepositoryNotFoundError
from re3data._resources import Re3Data, Repository, RepositoryList, RepositorySummary
from re3data._response import Response

__all__ = [
__all__ = (
"AsyncClient",
"Client",
"Re3Data",
"Re3dataError",
Expand All @@ -21,7 +22,7 @@
"Response",
"ReturnType",
"__version__",
]
)

_client = Client()
repositories = _client.repositories
12 changes: 12 additions & 0 deletions src/re3data/_client/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from re3data._client._async import AsyncClient, AsyncRepositoryManager
from re3data._client._sync import Client, RepositoryManager
from re3data._client.base import BaseClient, ReturnType

__all__ = (
"AsyncClient",
"AsyncRepositoryManager",
"BaseClient",
"Client",
"RepositoryManager",
"ReturnType",
)
172 changes: 172 additions & 0 deletions src/re3data/_client/_async.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
# SPDX-FileCopyrightText: 2024 Heinz-Alexander Fütterer
#
# SPDX-License-Identifier: MIT

from __future__ import annotations

import logging
from typing import TYPE_CHECKING, Literal, overload

import httpx

from re3data._client.base import BaseClient, Endpoint, ResourceType, ReturnType, is_valid_return_type
from re3data._exceptions import RepositoryNotFoundError
from re3data._response import Response, _build_response, _parse_repositories_response, _parse_repository_response

if TYPE_CHECKING:
from re3data._resources import Repository, RepositorySummary

logger = logging.getLogger(__name__)


async def async_log_response(response: httpx.Response) -> None:
"""Log the details of an HTTP response.
This function logs the HTTP method, URL, and status code of the response for debugging purposes.
It uses the 'debug' logging level to provide detailed diagnostic information.
Args:
response: The response object received from an HTTP request.
Returns:
None
"""
logger.debug(
"[http] Response: %s %s - Status %s", response.request.method, response.request.url, response.status_code
)


@overload
def _dispatch_return_type(
response: Response, resource_type: Literal[ResourceType.REPOSITORY], return_type: ReturnType
) -> Repository | Response | str: ...
@overload
def _dispatch_return_type(
response: Response, resource_type: Literal[ResourceType.REPOSITORY_LIST], return_type: ReturnType
) -> list[RepositorySummary] | Response | str: ...


def _dispatch_return_type(
response: Response, resource_type: ResourceType, return_type: ReturnType
) -> Repository | list[RepositorySummary] | Response | str:
"""Dispatch the response to the correct return type based on the provided return type and resource type.
Args:
response: The response object.
resource_type: The type of resource being processed.
return_type: The desired return type for the API resource.
Returns:
Depending on the return_type and resource_type, this can be a Repository object, a list of RepositorySummary
objects, an HTTP response, or the original XML.
"""
if return_type == ReturnType.DATACLASS:
if resource_type == ResourceType.REPOSITORY_LIST:
return _parse_repositories_response(response)
if resource_type == ResourceType.REPOSITORY:
return _parse_repository_response(response)
if return_type == ReturnType.XML:
return response.text
return response


class AsyncRepositoryManager:
"""A manager for interacting with repositories in the re3data API.
Attributes:
_client: The client used to make requests.
"""

def __init__(self, client: AsyncClient) -> None:
self._client = client

async def list(self, return_type: ReturnType = ReturnType.DATACLASS) -> list[RepositorySummary] | Response | str:
"""List the metadata of all repositories in the re3data API.
Args:
return_type: The desired return type for the API resource. Defaults to `ReturnType.DATACLASS`.
Returns:
Depending on the `return_type`, this can be a list of RepositorySummary objects, an HTTP response,
or the original XML.
Raises:
ValueError: If an invalid `return_type` is provided.
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)
return _dispatch_return_type(response, ResourceType.REPOSITORY_LIST, return_type)

async def get(
self, repository_id: str, return_type: ReturnType = ReturnType.DATACLASS
) -> Repository | Response | str:
"""Get the metadata of a specific repository.
Args:
repository_id: The identifier of the repository to retrieve.
return_type: The desired return type for the API resource. Defaults to `ReturnType.DATACLASS`.
Returns:
Depending on the `return_type`, this can be a Repository object, an HTTP response, or the original XML.
Raises:
ValueError: If an invalid `return_type` is provided.
httpx.HTTPStatusError: If the server returned an error status code >= 500.
RepositoryNotFoundError: If no repository with the given ID is found.
"""
is_valid_return_type(return_type)
response = await self._client._request(Endpoint.REPOSITORY.value.format(repository_id=repository_id))
if response.status_code == httpx.codes.NOT_FOUND:
raise RepositoryNotFoundError(f"No repository with id '{repository_id}' available at {response.url}.")
return _dispatch_return_type(response, ResourceType.REPOSITORY, return_type)


class AsyncClient(BaseClient):
"""A client that interacts with the re3data API.
Attributes:
_client: The underlying HTTP client.
_repository_manager: The repository manager to retrieve metadata from the repositories endpoints.
Examples:
>>> async_client = AsyncClient():
>>> response = await async_client.repositories.list()
>>> 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)
"""

_client: httpx.AsyncClient

def __init__(self) -> None:
super().__init__(httpx.AsyncClient)
self._client.event_hooks["response"] = [async_log_response]
self._repository_manager: AsyncRepositoryManager = AsyncRepositoryManager(self)

async def _request(self, path: str) -> Response:
"""Send a HTTP GET request to the specified API endpoint.
Args:
path: The path to send the request to.
Returns:
The response object from the HTTP request.
Raises:
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)
if http_response.is_server_error:
http_response.raise_for_status()
return _build_response(http_response)

@property
def repositories(self) -> AsyncRepositoryManager:
"""Get the repository manager for this client.
Returns:
The repository manager.
"""
return self._repository_manager
Loading

0 comments on commit b0c4a48

Please sign in to comment.