python-re3data-v2 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- python_re3data_v2-0.1.0.dist-info/METADATA +215 -0
- python_re3data_v2-0.1.0.dist-info/RECORD +19 -0
- python_re3data_v2-0.1.0.dist-info/WHEEL +4 -0
- python_re3data_v2-0.1.0.dist-info/entry_points.txt +2 -0
- python_re3data_v2-0.1.0.dist-info/licenses/LICENSE +21 -0
- re3data/__init__.py +31 -0
- re3data/__main__.py +6 -0
- re3data/_cli.py +114 -0
- re3data/_client/__init__.py +14 -0
- re3data/_client/_async.py +143 -0
- re3data/_client/_sync.py +145 -0
- re3data/_client/base.py +152 -0
- re3data/_exceptions.py +9 -0
- re3data/_parser.py +443 -0
- re3data/_resources/__init__.py +19 -0
- re3data/_resources/repository.py +564 -0
- re3data/_resources/repository_list.py +66 -0
- re3data/_response.py +34 -0
- re3data/_serializer.py +82 -0
re3data/_client/_sync.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""The synchronous client to interact with the re3data v40 API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import TYPE_CHECKING, Self
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from re3data._client.base import (
|
|
11
|
+
BaseClient,
|
|
12
|
+
Endpoint,
|
|
13
|
+
ResourceType,
|
|
14
|
+
ReturnType,
|
|
15
|
+
_dispatch_return_type,
|
|
16
|
+
build_query_params,
|
|
17
|
+
is_valid_return_type,
|
|
18
|
+
)
|
|
19
|
+
from re3data._exceptions import RepositoryNotFoundError
|
|
20
|
+
from re3data._response import Response, _build_response
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from re3data._resources import Repository, RepositorySummary
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def log_response(response: httpx.Response) -> None:
|
|
29
|
+
"""Log method, URL and status code of an HTTP response on the debug level."""
|
|
30
|
+
logger.debug("[http] Response: %s %s - Status %s", response.request.method, response.request.url, response.status_code)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class RepositoryManager:
|
|
34
|
+
"""A manager for interacting with the repositories endpoints."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, client: Client) -> None:
|
|
37
|
+
self._client = client
|
|
38
|
+
|
|
39
|
+
def list(
|
|
40
|
+
self,
|
|
41
|
+
query: str | None = None,
|
|
42
|
+
return_type: ReturnType = ReturnType.DATACLASS,
|
|
43
|
+
count: bool = False,
|
|
44
|
+
filters: dict[str, str | list[str] | tuple[str, ...]] | None = None,
|
|
45
|
+
) -> list[RepositorySummary] | Response | list[dict] | str | int:
|
|
46
|
+
"""List the metadata of repositories in the re3data API.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
query: A query string to filter the results. If provided, only
|
|
50
|
+
repositories matching the query will be returned.
|
|
51
|
+
return_type: The desired return type for the API resource.
|
|
52
|
+
Defaults to `ReturnType.DATACLASS`.
|
|
53
|
+
count: Whether to return the total number of matching items
|
|
54
|
+
instead of a list of repositories.
|
|
55
|
+
filters: Optional facet filters for the v40 endpoint, e.g.
|
|
56
|
+
``{"countries": ["DEU", "NZL"], "dataAccess": "open"}``.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
Depending on the `return_type`, this is a list of
|
|
60
|
+
RepositorySummary objects, an HTTP response, a dictionary/JSON/CSV
|
|
61
|
+
representation, the original XML, or the count of repositories.
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
ValueError: If an invalid `return_type` is provided.
|
|
65
|
+
httpx.HTTPStatusError: If the server returned an error status code >= 500.
|
|
66
|
+
"""
|
|
67
|
+
is_valid_return_type(return_type)
|
|
68
|
+
params = build_query_params(query, filters)
|
|
69
|
+
response = self._client._request(Endpoint.REPOSITORY_LIST.value, params)
|
|
70
|
+
return _dispatch_return_type(response, ResourceType.REPOSITORY_LIST, return_type, count)
|
|
71
|
+
|
|
72
|
+
def get(self, repository_id: str, return_type: ReturnType = ReturnType.DATACLASS) -> Repository | Response | dict | str:
|
|
73
|
+
"""Get the metadata of a specific repository.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
repository_id: The identifier of the repository to retrieve,
|
|
77
|
+
e.g. "r3d100012417".
|
|
78
|
+
return_type: The desired return type for the API resource.
|
|
79
|
+
Defaults to `ReturnType.DATACLASS`.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
Depending on the `return_type`, this is a Repository object, an
|
|
83
|
+
HTTP response, a dictionary/JSON/CSV representation or the
|
|
84
|
+
original XML.
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
ValueError: If an invalid `return_type` is provided.
|
|
88
|
+
httpx.HTTPStatusError: If the server returned an error status code >= 500.
|
|
89
|
+
RepositoryNotFoundError: If no repository with the given ID is found.
|
|
90
|
+
"""
|
|
91
|
+
is_valid_return_type(return_type)
|
|
92
|
+
response = self._client._request(Endpoint.REPOSITORY.value.format(repository_id=repository_id))
|
|
93
|
+
if response.status_code == httpx.codes.NOT_FOUND:
|
|
94
|
+
raise RepositoryNotFoundError(f"No repository with id '{repository_id}' available at {response.url}.")
|
|
95
|
+
return _dispatch_return_type(response, ResourceType.REPOSITORY, return_type)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class Client(BaseClient):
|
|
99
|
+
"""A client that interacts with the re3data v40 API.
|
|
100
|
+
|
|
101
|
+
Examples:
|
|
102
|
+
>>> client = Client()
|
|
103
|
+
>>> client.repositories.get("r3d100012417")
|
|
104
|
+
Repository(identifiers=Identifiers(re3data='r3d100012417', doi='https://doi.org/10.17616/R34F41'), ...)
|
|
105
|
+
>>> client.repositories.list(query="biosharing")
|
|
106
|
+
[RepositorySummary(id='r3d100010142', ...)]
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
_client: httpx.Client
|
|
110
|
+
|
|
111
|
+
def __init__(self, transport: httpx.BaseTransport | None = None) -> None:
|
|
112
|
+
"""Initialize the client.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
transport: An optional custom httpx transport, e.g. for testing.
|
|
116
|
+
"""
|
|
117
|
+
super().__init__(httpx.Client, transport)
|
|
118
|
+
self._client.event_hooks["response"] = [log_response]
|
|
119
|
+
self._repository_manager: RepositoryManager = RepositoryManager(self)
|
|
120
|
+
|
|
121
|
+
def __enter__(self) -> Self:
|
|
122
|
+
return self
|
|
123
|
+
|
|
124
|
+
def __exit__(self, *args: object) -> None:
|
|
125
|
+
self.close()
|
|
126
|
+
|
|
127
|
+
def close(self) -> None:
|
|
128
|
+
"""Close the underlying HTTP client and release connections."""
|
|
129
|
+
self._client.close()
|
|
130
|
+
|
|
131
|
+
def _request(self, path: str, query_params: list[tuple[str, str]] | None = None) -> Response:
|
|
132
|
+
"""Send an HTTP GET request to the specified API endpoint.
|
|
133
|
+
|
|
134
|
+
Raises:
|
|
135
|
+
httpx.HTTPStatusError: If the server returned an error status code >= 500.
|
|
136
|
+
"""
|
|
137
|
+
http_response = self._client.get(path, params=query_params)
|
|
138
|
+
if http_response.is_server_error:
|
|
139
|
+
http_response.raise_for_status()
|
|
140
|
+
return _build_response(http_response)
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def repositories(self) -> RepositoryManager:
|
|
144
|
+
"""The repository manager of this client."""
|
|
145
|
+
return self._repository_manager
|
re3data/_client/base.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Shared building blocks for the sync and async re3data API clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Literal, overload
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from re3data import __version__
|
|
11
|
+
from re3data._parser import parse_repository, parse_repository_list
|
|
12
|
+
from re3data._response import Response
|
|
13
|
+
from re3data._serializer import _to_csv, _to_dict, _to_json
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from re3data._resources import Repository, RepositorySummary
|
|
17
|
+
|
|
18
|
+
BASE_URL: str = "https://www.re3data.org/api/v40/"
|
|
19
|
+
DEFAULT_HEADERS: dict[str, str] = {
|
|
20
|
+
"Accept": "application/xml; charset=utf-8",
|
|
21
|
+
"User-Agent": f"python-re3data-v2/{__version__}",
|
|
22
|
+
}
|
|
23
|
+
DEFAULT_TIMEOUT = httpx.Timeout(timeout=10.0) # timeout in seconds
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Endpoint(StrEnum):
|
|
27
|
+
"""The re3data v40 API endpoints."""
|
|
28
|
+
|
|
29
|
+
REPOSITORY = "repository/{repository_id}"
|
|
30
|
+
REPOSITORY_LIST = "repositories"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ResourceType(StrEnum):
|
|
34
|
+
"""The type of an API resource."""
|
|
35
|
+
|
|
36
|
+
REPOSITORY = "repository"
|
|
37
|
+
REPOSITORY_LIST = "repositories"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ReturnType(StrEnum):
|
|
41
|
+
"""The supported serialization formats of API resources."""
|
|
42
|
+
|
|
43
|
+
CSV = "csv"
|
|
44
|
+
DATACLASS = "dataclass"
|
|
45
|
+
DICT = "dict"
|
|
46
|
+
JSON = "json"
|
|
47
|
+
RESPONSE = "response"
|
|
48
|
+
XML = "xml"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def is_valid_return_type(return_type: Any) -> None:
|
|
52
|
+
"""Validate that the given return type is a known :class:`ReturnType`.
|
|
53
|
+
|
|
54
|
+
Raises:
|
|
55
|
+
ValueError: If the given return type is not one of the allowed types.
|
|
56
|
+
"""
|
|
57
|
+
if not isinstance(return_type, ReturnType):
|
|
58
|
+
allowed_types = [return_type.value for return_type in ReturnType]
|
|
59
|
+
raise ValueError( # noqa: TRY004 - ValueError for compat with the original client
|
|
60
|
+
f"Invalid value for `return_type`: {return_type} is not one of {allowed_types}."
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def build_query_params(
|
|
65
|
+
query: str | None = None,
|
|
66
|
+
filters: dict[str, str | list[str] | tuple[str, ...]] | None = None,
|
|
67
|
+
) -> list[tuple[str, str]]:
|
|
68
|
+
"""Build the query parameters for the list endpoint.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
query: An optional search term, sent as the ``query`` parameter.
|
|
72
|
+
filters: Optional facet filters. Keys are the v40 filter names without
|
|
73
|
+
brackets (e.g. ``countries``, ``dataAccess``, ``subjects``); values
|
|
74
|
+
are one value or a sequence of values. They are serialized as
|
|
75
|
+
repeated ``name[]`` parameters, e.g. ``countries[]=NZL``.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
A list of query parameter pairs, ready for ``httpx``.
|
|
79
|
+
"""
|
|
80
|
+
params: list[tuple[str, str]] = []
|
|
81
|
+
if query:
|
|
82
|
+
params.append(("query", query))
|
|
83
|
+
for name, raw_values in (filters or {}).items():
|
|
84
|
+
values = (raw_values,) if isinstance(raw_values, str) else raw_values
|
|
85
|
+
params.extend((f"{name}[]", str(value)) for value in values)
|
|
86
|
+
return params
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@overload
|
|
90
|
+
def _dispatch_return_type(
|
|
91
|
+
response: Response, resource_type: Literal[ResourceType.REPOSITORY], return_type: ReturnType
|
|
92
|
+
) -> Repository | Response | dict | str: ...
|
|
93
|
+
@overload
|
|
94
|
+
def _dispatch_return_type(
|
|
95
|
+
response: Response, resource_type: Literal[ResourceType.REPOSITORY_LIST], return_type: ReturnType, count: bool = False
|
|
96
|
+
) -> list[RepositorySummary] | Response | list[dict] | str | int: ...
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _dispatch_return_type( # noqa: PLR0911 - mirrors the upstream dispatch structure
|
|
100
|
+
response: Response, resource_type: ResourceType, return_type: ReturnType, count: bool = False
|
|
101
|
+
) -> Repository | list[RepositorySummary] | Response | dict | list[dict] | str | int:
|
|
102
|
+
"""Dispatch a response to the requested return type.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
response: The response object.
|
|
106
|
+
resource_type: The type of resource being processed.
|
|
107
|
+
return_type: The desired return type for the API resource.
|
|
108
|
+
count: Whether to return the total number of matching items instead of
|
|
109
|
+
a list of repositories (list endpoint only).
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
Depending on the return type: a typed model, the response object, the
|
|
113
|
+
raw XML, a dict/JSON/CSV serialization or the repository count.
|
|
114
|
+
"""
|
|
115
|
+
if resource_type is ResourceType.REPOSITORY_LIST and count:
|
|
116
|
+
return response.text.count("<repository>")
|
|
117
|
+
if return_type is ReturnType.RESPONSE:
|
|
118
|
+
return response
|
|
119
|
+
if return_type is ReturnType.XML:
|
|
120
|
+
return response.text
|
|
121
|
+
|
|
122
|
+
parsed = (
|
|
123
|
+
parse_repository_list(response.text).repository
|
|
124
|
+
if resource_type is ResourceType.REPOSITORY_LIST
|
|
125
|
+
else parse_repository(response.text)
|
|
126
|
+
)
|
|
127
|
+
if return_type is ReturnType.DATACLASS:
|
|
128
|
+
return parsed
|
|
129
|
+
if return_type is ReturnType.DICT:
|
|
130
|
+
return _to_dict(parsed)
|
|
131
|
+
if return_type is ReturnType.JSON:
|
|
132
|
+
return _to_json(parsed)
|
|
133
|
+
return _to_csv(parsed)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class BaseClient:
|
|
137
|
+
"""A base class for clients that interact with the re3data v40 API."""
|
|
138
|
+
|
|
139
|
+
def __init__(
|
|
140
|
+
self,
|
|
141
|
+
client: type[httpx.Client | httpx.AsyncClient],
|
|
142
|
+
transport: httpx.BaseTransport | httpx.AsyncBaseTransport | None = None,
|
|
143
|
+
) -> None:
|
|
144
|
+
kwargs: dict[str, object] = {
|
|
145
|
+
"base_url": BASE_URL,
|
|
146
|
+
"headers": DEFAULT_HEADERS,
|
|
147
|
+
"timeout": DEFAULT_TIMEOUT,
|
|
148
|
+
"follow_redirects": True,
|
|
149
|
+
}
|
|
150
|
+
if transport is not None:
|
|
151
|
+
kwargs["transport"] = transport
|
|
152
|
+
self._client = client(**kwargs)
|
re3data/_exceptions.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Exception classes for errors encountered when using the re3data API."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Re3dataError(Exception):
|
|
5
|
+
"""Base exception class for errors related to the re3data API."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RepositoryNotFoundError(Re3dataError):
|
|
9
|
+
"""Exception raised when a repository is not found."""
|