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.
@@ -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
@@ -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."""