diff --git a/pyDataverse/api.py b/pyDataverse/api.py
index a15decc..b364847 100644
--- a/pyDataverse/api.py
+++ b/pyDataverse/api.py
@@ -4,9 +4,11 @@
from typing import Any, Dict, Optional
import httpx
import subprocess as sp
+from warnings import warn
from httpx import ConnectError, Response
+from pyDataverse.auth import ApiTokenAuth
from pyDataverse.exceptions import (
ApiAuthorizationError,
ApiUrlError,
@@ -16,6 +18,8 @@
OperationFailedError,
)
+DEPRECATION_GUARD = object()
+
class Api:
"""Base class.
@@ -41,6 +45,8 @@ def __init__(
base_url: str,
api_token: Optional[str] = None,
api_version: str = "latest",
+ *,
+ auth: Optional[httpx.Auth] = None,
):
"""Init an Api() class.
@@ -52,16 +58,54 @@ def __init__(
base_url : str
Base url for Dataverse api.
api_token : str | None
- Api token for Dataverse api.
+ API token for Dataverse API. If you provide an :code:`api_token`, we
+ assume it is an API token as retrieved via your Dataverse instance
+ user profile.
+ We recommend using the :code:`auth` argument instead.
+ To retain the current behaviour with the :code:`auth` argument, change
+
+ .. code-block:: python
+
+ Api("https://demo.dataverse.org", "my_token")
+
+ to
+
+ .. code-block:: python
+
+ from pyDataverse.auth import ApiTokenAuth
+ Api("https://demo.dataverse.org", auth=ApiTokenAuth("my_token"))
+
+ If you are using an OIDC/OAuth 2.0 Bearer token, please use the :code:`auth`
+ parameter with the :py:class:`.auth.BearerTokenAuth`.
+ api_version : str
+ The version string of the Dataverse API or :code:`latest`, e.g.,
+ :code:`v1`. Defaults to :code:`latest`, which drops the version from
+ the API urls.
+ auth : httpx.Auth | None
+ You can provide any authentication mechanism you like to connect to
+ your Dataverse instance. The most common mechanisms are implemented
+ in :py:mod:`.auth`, but if one is missing, you can use your own
+ `httpx.Auth`-compatible class. For more information, have a look at
+ `httpx' Authentication docs
+ `_.
Examples
- --------
- Create an Api connection::
+ -------
+ Create an API connection::
+
+ .. code-block::
>>> from pyDataverse.api import Api
>>> base_url = 'http://demo.dataverse.org'
>>> api = Api(base_url)
+ .. code-block::
+
+ >>> from pyDataverse.api import Api
+ >>> from pyDataverse.auth import ApiTokenAuth
+ >>> base_url = 'http://demo.dataverse.org'
+ >>> api = Api(base_url, ApiTokenAuth('my_api_token'))
+
"""
if not isinstance(base_url, str):
raise ApiUrlError("base_url {0} is not a string.".format(base_url))
@@ -69,28 +113,33 @@ def __init__(
self.base_url = base_url
self.client = None
- if not isinstance(api_version, ("".__class__, "".__class__)):
+ if not isinstance(api_version, str):
raise ApiUrlError("api_version {0} is not a string.".format(api_version))
self.api_version = api_version
- if api_token:
- if not isinstance(api_token, ("".__class__, "".__class__)):
- raise ApiAuthorizationError("Api token passed is not a string.")
+ self.auth = auth
self.api_token = api_token
-
- if self.base_url:
- if self.api_version == "latest":
- self.base_url_api = "{0}/api".format(self.base_url)
+ if api_token is not None:
+ if auth is None:
+ self.auth = ApiTokenAuth(api_token)
else:
- self.base_url_api = "{0}/api/{1}".format(
- self.base_url, self.api_version
+ self.api_token = None
+ warn(
+ UserWarning(
+ "You provided both, an api_token and a custom auth "
+ "method. We will only use the auth method."
+ )
)
+
+ if self.api_version == "latest":
+ self.base_url_api = "{0}/api".format(self.base_url)
else:
- self.base_url_api = None
+ self.base_url_api = "{0}/api/{1}".format(self.base_url, self.api_version)
+
self.timeout = 500
def __str__(self):
- """Return name of Api() class for users.
+ """Return the class name and URL of the used API class.
Returns
-------
@@ -98,9 +147,9 @@ def __str__(self):
Naming of the API class.
"""
- return "API: {0}".format(self.base_url_api)
+ return f"{self.__class__.__name__}: {self.base_url_api}"
- def get_request(self, url, params=None, auth=False):
+ def get_request(self, url, params=None, auth=DEPRECATION_GUARD):
"""Make a GET request.
Parameters
@@ -110,8 +159,18 @@ def get_request(self, url, params=None, auth=False):
params : dict
Dictionary of parameters to be passed with the request.
Defaults to `None`.
- auth : bool
- Should an api token be sent in the request. Defaults to `False`.
+ auth : Any
+ .. deprecated:: 0.3.4
+ The auth parameter was ignored before version 0.3.4.
+ Please pass your auth to the Api instance directly, as
+ explained in :py:func:`Api.__init__`.
+ If you need multiple auth methods, create multiple
+ API instances:
+
+ .. code-block:: python
+
+ api = Api("https://demo.dataverse.org", auth=ApiTokenAuth("my_api_token"))
+ api_oauth = Api("https://demo.dataverse.org", auth=BearerTokenAuth("my_bearer_token"))
Returns
-------
@@ -119,25 +178,34 @@ def get_request(self, url, params=None, auth=False):
Response object of httpx library.
"""
- params = {}
- params["User-Agent"] = "pydataverse"
- if self.api_token:
- params["key"] = str(self.api_token)
+ if auth is not DEPRECATION_GUARD:
+ warn(
+ DeprecationWarning(
+ "The auth parameter is deprecated. Please pass your auth "
+ "arguments to the __init__ method instead."
+ )
+ )
+ headers = {}
+ headers["User-Agent"] = "pydataverse"
if self.client is None:
return self._sync_request(
method=httpx.get,
url=url,
+ headers=headers,
params=params,
)
else:
return self._async_request(
method=self.client.get,
url=url,
+ headers=headers,
params=params,
)
- def post_request(self, url, data=None, auth=False, params=None, files=None):
+ def post_request(
+ self, url, data=None, auth=DEPRECATION_GUARD, params=None, files=None
+ ):
"""Make a POST request.
params will be added as key-value pairs to the URL.
@@ -148,8 +216,18 @@ def post_request(self, url, data=None, auth=False, params=None, files=None):
Full URL.
data : str
Metadata as a json-formatted string. Defaults to `None`.
- auth : bool
- Should an api token be sent in the request. Defaults to `False`.
+ auth : Any
+ .. deprecated:: 0.3.4
+ The auth parameter was ignored before version 0.3.4.
+ Please pass your auth to the Api instance directly, as
+ explained in :py:func:`Api.__init__`.
+ If you need multiple auth methods, create multiple
+ API instances:
+
+ .. code-block:: python
+
+ api = Api("https://demo.dataverse.org", auth=ApiTokenAuth("my_api_token"))
+ api_oauth = Api("https://demo.dataverse.org", auth=BearerTokenAuth("my_bearer_token"))
files : dict
e.g. :code:`files={'file': open('sample_file.txt','rb')}`
params : dict
@@ -162,10 +240,15 @@ def post_request(self, url, data=None, auth=False, params=None, files=None):
Response object of httpx library.
"""
- params = {}
- params["User-Agent"] = "pydataverse"
- if self.api_token:
- params["key"] = self.api_token
+ if auth is not DEPRECATION_GUARD:
+ warn(
+ DeprecationWarning(
+ "The auth parameter is deprecated. Please pass your auth "
+ "arguments to the __init__ method instead."
+ )
+ )
+ headers = {}
+ headers["User-Agent"] = "pydataverse"
if isinstance(data, str):
data = json.loads(data)
@@ -175,18 +258,24 @@ def post_request(self, url, data=None, auth=False, params=None, files=None):
if self.client is None:
return self._sync_request(
- method=httpx.post, url=url, params=params, files=files, **request_params
+ method=httpx.post,
+ url=url,
+ headers=headers,
+ params=params,
+ files=files,
+ **request_params,
)
else:
return self._async_request(
method=self.client.post,
url=url,
+ headers=headers,
params=params,
files=files,
**request_params,
)
- def put_request(self, url, data=None, auth=False, params=None):
+ def put_request(self, url, data=None, auth=DEPRECATION_GUARD, params=None):
"""Make a PUT request.
Parameters
@@ -195,8 +284,18 @@ def put_request(self, url, data=None, auth=False, params=None):
Full URL.
data : str
Metadata as a json-formatted string. Defaults to `None`.
- auth : bool
- Should an api token be sent in the request. Defaults to `False`.
+ auth : Any
+ .. deprecated:: 0.3.4
+ The auth parameter was ignored before version 0.3.4.
+ Please pass your auth to the Api instance directly, as
+ explained in :py:func:`Api.__init__`.
+ If you need multiple auth methods, create multiple
+ API instances:
+
+ .. code-block:: python
+
+ api = Api("https://demo.dataverse.org", auth=ApiTokenAuth("my_api_token"))
+ api_oauth = Api("https://demo.dataverse.org", auth=BearerTokenAuth("my_bearer_token"))
params : dict
Dictionary of parameters to be passed with the request.
Defaults to `None`.
@@ -207,10 +306,15 @@ def put_request(self, url, data=None, auth=False, params=None):
Response object of httpx library.
"""
- params = {}
- params["User-Agent"] = "pydataverse"
- if self.api_token:
- params["key"] = self.api_token
+ if auth is not DEPRECATION_GUARD:
+ warn(
+ DeprecationWarning(
+ "The auth parameter is deprecated. Please pass your auth "
+ "arguments to the __init__ method instead."
+ )
+ )
+ headers = {}
+ headers["User-Agent"] = "pydataverse"
if isinstance(data, str):
data = json.loads(data)
@@ -222,6 +326,8 @@ def put_request(self, url, data=None, auth=False, params=None):
return self._sync_request(
method=httpx.put,
url=url,
+ json=data,
+ headers=headers,
params=params,
**request_params,
)
@@ -229,19 +335,31 @@ def put_request(self, url, data=None, auth=False, params=None):
return self._async_request(
method=self.client.put,
url=url,
+ json=data,
+ headers=headers,
params=params,
**request_params,
)
- def delete_request(self, url, auth=False, params=None):
+ def delete_request(self, url, auth=DEPRECATION_GUARD, params=None):
"""Make a Delete request.
Parameters
----------
url : str
Full URL.
- auth : bool
- Should an api token be sent in the request. Defaults to `False`.
+ auth : Any
+ .. deprecated:: 0.3.4
+ The auth parameter was ignored before version 0.3.4.
+ Please pass your auth to the Api instance directly, as
+ explained in :py:func:`Api.__init__`.
+ If you need multiple auth methods, create multiple
+ API instances:
+
+ .. code-block:: python
+
+ api = Api("https://demo.dataverse.org", auth=ApiTokenAuth("my_api_token"))
+ api_oauth = Api("https://demo.dataverse.org", auth=BearerTokenAuth("my_bearer_token"))
params : dict
Dictionary of parameters to be passed with the request.
Defaults to `None`.
@@ -252,21 +370,28 @@ def delete_request(self, url, auth=False, params=None):
Response object of httpx library.
"""
- params = {}
- params["User-Agent"] = "pydataverse"
- if self.api_token:
- params["key"] = self.api_token
+ if auth is not DEPRECATION_GUARD:
+ warn(
+ DeprecationWarning(
+ "The auth parameter is deprecated. Please pass your auth "
+ "arguments to the __init__ method instead."
+ )
+ )
+ headers = {}
+ headers["User-Agent"] = "pydataverse"
if self.client is None:
return self._sync_request(
method=httpx.delete,
url=url,
+ headers=headers,
params=params,
)
else:
return self._async_request(
method=self.client.delete,
url=url,
+ headers=headers,
params=params,
)
@@ -321,9 +446,14 @@ def _sync_request(
kwargs = self._filter_kwargs(kwargs)
try:
- resp = method(**kwargs, follow_redirects=True, timeout=None)
+ resp: httpx.Response = method(
+ **kwargs, auth=self.auth, follow_redirects=True, timeout=None
+ )
if resp.status_code == 401:
- error_msg = resp.json()["message"]
+ try:
+ error_msg = resp.json()["message"]
+ except json.JSONDecodeError:
+ error_msg = resp.reason_phrase
raise ApiAuthorizationError(
"ERROR: HTTP 401 - Authorization error {0}. MSG: {1}".format(
kwargs["url"], error_msg
@@ -364,7 +494,7 @@ async def _async_request(
kwargs = self._filter_kwargs(kwargs)
try:
- resp = await method(**kwargs)
+ resp = await method(**kwargs, auth=self.auth)
if resp.status_code == 401:
error_msg = resp.json()["message"]
@@ -431,25 +561,14 @@ class DataAccessApi(Api):
"""
- def __init__(self, base_url, api_token=None):
+ def __init__(self, base_url, api_token=None, *, auth=None):
"""Init an DataAccessApi() class."""
- super().__init__(base_url, api_token)
+ super().__init__(base_url, api_token, auth=auth)
if base_url:
self.base_url_api_data_access = "{0}/access".format(self.base_url_api)
else:
self.base_url_api_data_access = self.base_url_api
- def __str__(self):
- """Return name of DataAccessApi() class for users.
-
- Returns
- -------
- str
- Naming of the DataAccess API class.
-
- """
- return "Data Access API: {0}".format(self.base_url_api_data_access)
-
def get_datafile(
self,
identifier,
@@ -457,7 +576,7 @@ def get_datafile(
no_var_header=None,
image_thumb=None,
is_pid=True,
- auth=False,
+ auth=DEPRECATION_GUARD,
):
"""Download a datafile via the Dataverse Data Access API.
@@ -510,7 +629,7 @@ def get_datafile(
url += "imageThumb={0}".format(image_thumb)
return self.get_request(url, auth=auth)
- def get_datafiles(self, identifier, data_format=None, auth=False):
+ def get_datafiles(self, identifier, data_format=None, auth=DEPRECATION_GUARD):
"""Download a datafile via the Dataverse Data Access API.
Get by file id (HTTP Request).
@@ -538,7 +657,9 @@ def get_datafiles(self, identifier, data_format=None, auth=False):
url += "?format={0}".format(data_format)
return self.get_request(url, auth=auth)
- def get_datafile_bundle(self, identifier, file_metadata_id=None, auth=False):
+ def get_datafile_bundle(
+ self, identifier, file_metadata_id=None, auth=DEPRECATION_GUARD
+ ):
"""Download a datafile in all its formats.
HTTP Request:
@@ -581,7 +702,7 @@ def get_datafile_bundle(self, identifier, file_metadata_id=None, auth=False):
url += "?fileMetadataId={0}".format(file_metadata_id)
return self.get_request(url, auth=auth)
- def request_access(self, identifier, auth=True, is_filepid=False):
+ def request_access(self, identifier, auth=DEPRECATION_GUARD, is_filepid=False):
"""Request datafile access.
This method requests access to the datafile whose id is passed on the behalf of an authenticated user whose key is passed. Note that not all datasets allow access requests to restricted files.
@@ -602,7 +723,9 @@ def request_access(self, identifier, auth=True, is_filepid=False):
)
return self.put_request(url, auth=auth)
- def allow_access_request(self, identifier, do_allow=True, auth=True, is_pid=True):
+ def allow_access_request(
+ self, identifier, do_allow=True, auth=DEPRECATION_GUARD, is_pid=True
+ ):
"""Allow access request for datafiles.
https://guides.dataverse.org/en/latest/api/dataaccess.html#allow-access-requests
@@ -625,7 +748,7 @@ def allow_access_request(self, identifier, do_allow=True, auth=True, is_pid=True
data = "false"
return self.put_request(url, data=data, auth=auth)
- def grant_file_access(self, identifier, user, auth=False):
+ def grant_file_access(self, identifier, user, auth=DEPRECATION_GUARD):
"""Grant datafile access.
https://guides.dataverse.org/en/4.18.1/api/dataaccess.html#grant-file-access
@@ -637,7 +760,7 @@ def grant_file_access(self, identifier, user, auth=False):
)
return self.put_request(url, auth=auth)
- def list_file_access_requests(self, identifier, auth=False):
+ def list_file_access_requests(self, identifier, auth=DEPRECATION_GUARD):
"""Liste datafile access requests.
https://guides.dataverse.org/en/4.18.1/api/dataaccess.html#list-file-access-requests
@@ -662,26 +785,15 @@ class MetricsApi(Api):
"""
- def __init__(self, base_url, api_token=None, api_version="latest"):
+ def __init__(self, base_url, api_token=None, api_version="latest", *, auth=None):
"""Init an MetricsApi() class."""
- super().__init__(base_url, api_token, api_version)
+ super().__init__(base_url, api_token, api_version, auth=auth)
if base_url:
self.base_url_api_metrics = "{0}/api/info/metrics".format(self.base_url)
else:
self.base_url_api_metrics = None
- def __str__(self):
- """Return name of MetricsApi() class for users.
-
- Returns
- -------
- str
- Naming of the MetricsApi() class.
-
- """
- return "Metrics API: {0}".format(self.base_url_api_metrics)
-
- def total(self, data_type, date_str=None, auth=False):
+ def total(self, data_type, date_str=None, auth=DEPRECATION_GUARD):
"""
GET https://$SERVER/api/info/metrics/$type
GET https://$SERVER/api/info/metrics/$type/toMonth/$YYYY-DD
@@ -694,7 +806,7 @@ def total(self, data_type, date_str=None, auth=False):
url += "/toMonth/{0}".format(date_str)
return self.get_request(url, auth=auth)
- def past_days(self, data_type, days_str, auth=False):
+ def past_days(self, data_type, days_str, auth=DEPRECATION_GUARD):
"""
http://guides.dataverse.org/en/4.18.1/api/metrics.html
@@ -708,7 +820,7 @@ def past_days(self, data_type, days_str, auth=False):
)
return self.get_request(url, auth=auth)
- def get_dataverses_by_subject(self, auth=False):
+ def get_dataverses_by_subject(self, auth=DEPRECATION_GUARD):
"""
GET https://$SERVER/api/info/metrics/dataverses/bySubject
@@ -718,7 +830,7 @@ def get_dataverses_by_subject(self, auth=False):
url = "{0}/dataverses/bySubject".format(self.base_url_api_metrics)
return self.get_request(url, auth=auth)
- def get_dataverses_by_category(self, auth=False):
+ def get_dataverses_by_category(self, auth=DEPRECATION_GUARD):
"""
GET https://$SERVER/api/info/metrics/dataverses/byCategory
@@ -728,7 +840,7 @@ def get_dataverses_by_category(self, auth=False):
url = "{0}/dataverses/byCategory".format(self.base_url_api_metrics)
return self.get_request(url, auth=auth)
- def get_datasets_by_subject(self, date_str=None, auth=False):
+ def get_datasets_by_subject(self, date_str=None, auth=DEPRECATION_GUARD):
"""
GET https://$SERVER/api/info/metrics/datasets/bySubject
@@ -740,7 +852,7 @@ def get_datasets_by_subject(self, date_str=None, auth=False):
url += "/toMonth/{0}".format(date_str)
return self.get_request(url, auth=auth)
- def get_datasets_by_data_location(self, data_location, auth=False):
+ def get_datasets_by_data_location(self, data_location, auth=DEPRECATION_GUARD):
"""
GET https://$SERVER/api/info/metrics/datasets/bySubject
@@ -774,7 +886,7 @@ class NativeApi(Api):
"""
- def __init__(self, base_url: str, api_token=None, api_version="v1"):
+ def __init__(self, base_url: str, api_token=None, api_version="v1", *, auth=None):
"""Init an Api() class.
Scheme, host and path combined create the base-url for the api.
@@ -783,24 +895,13 @@ def __init__(self, base_url: str, api_token=None, api_version="v1"):
Parameters
----------
native_api_version : str
- Api version of Dataverse native api. Default is `v1`.
+ API version of Dataverse native API. Default is `v1`.
"""
- super().__init__(base_url, api_token, api_version)
+ super().__init__(base_url, api_token, api_version, auth=auth)
self.base_url_api_native = self.base_url_api
- def __str__(self):
- """Return name of NativeApi() class for users.
-
- Returns
- -------
- str
- Naming of the NativeApi() class.
-
- """
- return "Native API: {0}".format(self.base_url_api_native)
-
- def get_dataverse(self, identifier, auth=False):
+ def get_dataverse(self, identifier, auth=DEPRECATION_GUARD):
"""Get dataverse metadata by alias or id.
View metadata about a dataverse.
@@ -1041,7 +1142,7 @@ def get_dataverse_contents(self, identifier, auth=True):
url = "{0}/dataverses/{1}/contents".format(self.base_url_api_native, identifier)
return self.get_request(url, auth=auth)
- def get_dataverse_assignments(self, identifier, auth=False):
+ def get_dataverse_assignments(self, identifier, auth=DEPRECATION_GUARD):
"""Get dataverse assignments by alias or id.
View assignments of a dataverse.
@@ -1067,7 +1168,7 @@ def get_dataverse_assignments(self, identifier, auth=False):
)
return self.get_request(url, auth=auth)
- def get_dataverse_facets(self, identifier, auth=False):
+ def get_dataverse_facets(self, identifier, auth=DEPRECATION_GUARD):
"""Get dataverse facets by alias or id.
View facets of a dataverse.
@@ -1091,7 +1192,7 @@ def get_dataverse_facets(self, identifier, auth=False):
url = "{0}/dataverses/{1}/facets".format(self.base_url_api_native, identifier)
return self.get_request(url, auth=auth)
- def dataverse_id2alias(self, dataverse_id, auth=False):
+ def dataverse_id2alias(self, dataverse_id, auth=DEPRECATION_GUARD):
"""Converts a Dataverse ID to an alias.
Parameters
@@ -1240,7 +1341,7 @@ def get_dataset_version(self, identifier, version, auth=True, is_pid=True):
)
return self.get_request(url, auth=auth)
- def get_dataset_export(self, pid, export_format, auth=False):
+ def get_dataset_export(self, pid, export_format, auth=DEPRECATION_GUARD):
"""Get metadata of dataset exported in different formats.
Export the metadata of the current published version of a dataset
@@ -1950,7 +2051,7 @@ def replace_datafile(self, identifier, filename, json_str, is_filepid=True):
url += "/files/{0}/replace".format(identifier)
return self.post_request(url, data=data, files=files, auth=True)
- def get_info_version(self, auth=False):
+ def get_info_version(self, auth=DEPRECATION_GUARD):
"""Get the Dataverse version and build number.
The response contains the version and build numbers. Requires no api
@@ -1971,7 +2072,7 @@ def get_info_version(self, auth=False):
url = "{0}/info/version".format(self.base_url_api_native)
return self.get_request(url, auth=auth)
- def get_info_server(self, auth=False):
+ def get_info_server(self, auth=DEPRECATION_GUARD):
"""Get dataverse server name.
This is useful when a Dataverse system is composed of multiple Java EE
@@ -1992,7 +2093,7 @@ def get_info_server(self, auth=False):
url = "{0}/info/server".format(self.base_url_api_native)
return self.get_request(url, auth=auth)
- def get_info_api_terms_of_use(self, auth=False):
+ def get_info_api_terms_of_use(self, auth=DEPRECATION_GUARD):
"""Get API Terms of Use url.
The response contains the text value inserted as API Terms of use which
@@ -2013,7 +2114,7 @@ def get_info_api_terms_of_use(self, auth=False):
url = "{0}/info/apiTermsOfUse".format(self.base_url_api_native)
return self.get_request(url, auth=auth)
- def get_metadatablocks(self, auth=False):
+ def get_metadatablocks(self, auth=DEPRECATION_GUARD):
"""Get info about all metadata blocks.
Lists brief info about all metadata blocks registered in the system.
@@ -2033,7 +2134,7 @@ def get_metadatablocks(self, auth=False):
url = "{0}/metadatablocks".format(self.base_url_api_native)
return self.get_request(url, auth=auth)
- def get_metadatablock(self, identifier, auth=False):
+ def get_metadatablock(self, identifier, auth=DEPRECATION_GUARD):
"""Get info about single metadata block.
Returns data about the block whose identifier is passed. identifier can
@@ -2059,7 +2160,7 @@ def get_metadatablock(self, identifier, auth=False):
url = "{0}/metadatablocks/{1}".format(self.base_url_api_native, identifier)
return self.get_request(url, auth=auth)
- def get_user_api_token_expiration_date(self, auth=False):
+ def get_user_api_token_expiration_date(self, auth=DEPRECATION_GUARD):
"""Get the expiration date of an Users's API token.
HTTP Request:
@@ -2138,7 +2239,7 @@ def create_role(self, dataverse_id):
url = "{0}/roles?dvo={1}".format(self.base_url_api_native, dataverse_id)
return self.post_request(url)
- def show_role(self, role_id, auth=False):
+ def show_role(self, role_id, auth=DEPRECATION_GUARD):
"""Show role.
`Docs `_
@@ -2459,25 +2560,14 @@ class SearchApi(Api):
"""
- def __init__(self, base_url, api_token=None, api_version="latest"):
+ def __init__(self, base_url, api_token=None, api_version="latest", *, auth=None):
"""Init an SearchApi() class."""
- super().__init__(base_url, api_token, api_version)
+ super().__init__(base_url, api_token, api_version, auth=auth)
if base_url:
self.base_url_api_search = "{0}/search?q=".format(self.base_url_api)
else:
self.base_url_api_search = self.base_url_api
- def __str__(self):
- """Return name of SearchApi() class for users.
-
- Returns
- -------
- str
- Naming of the Search API class.
-
- """
- return "Search API: {0}".format(self.base_url_api_search)
-
def search(
self,
q_str,
@@ -2492,7 +2582,7 @@ def search(
filter_query=None,
show_entity_ids=None,
query_entities=None,
- auth=False,
+ auth=DEPRECATION_GUARD,
):
"""Search.
@@ -2547,7 +2637,13 @@ class SwordApi(Api):
"""
def __init__(
- self, base_url, api_version="v1.1", api_token=None, sword_api_version="v1.1"
+ self,
+ base_url,
+ api_version="v1.1",
+ api_token=None,
+ sword_api_version="v1.1",
+ *,
+ auth=None,
):
"""Init a :class:`SwordApi ` instance.
@@ -2555,9 +2651,27 @@ def __init__(
----------
sword_api_version : str
Api version of Dataverse SWORD API.
+ api_token : str | None
+ An Api token as retrieved from your Dataverse instance.
+ auth : httpx.Auth
+ Note that the SWORD API uses a different authentication mechanism
+ than the native API, in particular it uses `HTTP Basic
+ Authentication
+ `_.
+ Thus, if you pass an api_token, it will be used as the username in
+ the HTTP Basic Authentication. If you pass a custom :py:class:`httpx.Auth`, use
+ :py:class:`httpx.BasicAuth` with an empty password:
+
+ .. code-block:: python
+
+ sword_api = Api(
+ "https://demo.dataverse.org", auth=httpx.BasicAuth(username="my_token", password="")
+ )
"""
- super().__init__(base_url, api_token, api_version)
+ if auth is None and api_token is not None:
+ auth = httpx.BasicAuth(api_token, "")
+ super().__init__(base_url, api_token, api_version, auth=auth)
if not isinstance(sword_api_version, ("".__class__, "".__class__)):
raise ApiUrlError(
"sword_api_version {0} is not a string.".format(sword_api_version)
@@ -2572,17 +2686,6 @@ def __init__(
else:
self.base_url_api_sword = base_url
- def __str__(self):
- """Return name of :class:Api() class for users.
-
- Returns
- -------
- str
- Naming of the SWORD API class.
-
- """
- return "SWORD API: {0}".format(self.base_url_api_sword)
-
def get_service_document(self):
url = "{0}/swordv2/service-document".format(self.base_url_api_sword)
return self.get_request(url, auth=True)
diff --git a/pyDataverse/auth.py b/pyDataverse/auth.py
new file mode 100644
index 0000000..3e8c62b
--- /dev/null
+++ b/pyDataverse/auth.py
@@ -0,0 +1,103 @@
+"""This module contains authentication handlers compatible with :class:`httpx.Auth`"""
+
+from typing import Generator
+
+from httpx import Auth, Request, Response
+
+from pyDataverse.exceptions import ApiAuthorizationError
+
+
+class ApiTokenAuth(Auth):
+ """An authentication handler to add an API token as the X-Dataverse-key
+ header.
+
+ For more information on how to retrieve an API token and how it is used,
+ please refer to https://guides.dataverse.org/en/latest/api/auth.html.
+ """
+
+ def __init__(self, api_token: str):
+ """Initializes the auth handler with an API token.
+
+ Parameters
+ ----------
+ api_token : str
+ The API token retrieved from your Dataverse instance user profile.
+
+ Examples
+ --------
+
+ >>> import os
+ >>> from pyDataverse.api import DataAccessApi
+ >>> base_url = 'https://demo.dataverse.org'
+ >>> api_token_auth = ApiTokenAuth(os.getenv('API_TOKEN'))
+ >>> api = DataAccessApi(base_url, api_token_auth)
+
+ """
+ if not isinstance(api_token, str):
+ raise ApiAuthorizationError("API token passed is not a string.")
+ self.api_token = api_token
+
+ def auth_flow(self, request: Request) -> Generator[Request, Response, None]:
+ """Adds the X-Dataverse-key header with the API token and yields the
+ original :class:`httpx.Request`.
+
+ Parameters
+ ----------
+ request : httpx.Request
+ The request object which requires authentication headers
+
+ Yields
+ ------
+ httpx.Request
+ The original request with modified headers
+ """
+ request.headers["X-Dataverse-key"] = self.api_token
+ yield request
+
+
+class BearerTokenAuth(Auth):
+ """An authentication handler to add a Bearer token as defined in `RFC 6750
+ `_ to the request.
+
+ A bearer token could be obtained from an OIDC provider, for example,
+ Keycloak.
+ """
+
+ def __init__(self, bearer_token: str):
+ """Initializes the auth handler with a bearer token.
+
+ Parameters
+ ----------
+ bearer_token : str
+ The bearer token retrieved from your OIDC provider.
+
+ Examples
+ --------
+
+ >>> import os
+ >>> from pyDataverse.api import DataAccessApi
+ >>> base_url = 'https://demo.dataverse.org'
+ >>> bearer_token_auth = OAuthBearerTokenAuth(os.getenv('OAUTH_TOKEN'))
+ >>> api = DataAccessApi(base_url, bearer_token_auth)
+
+ """
+ if not isinstance(bearer_token, str):
+ raise ApiAuthorizationError("API token passed is not a string.")
+ self.bearer_token = bearer_token
+
+ def auth_flow(self, request: Request) -> Generator[Request, Response, None]:
+ """Adds the X-Dataverse-key header with the API token and yields the
+ original :class:`httpx.Request`.
+
+ Parameters
+ ----------
+ request : httpx.Request
+ The request object which requires authentication headers
+
+ Yields
+ ------
+ httpx.Request
+ The original request with modified headers
+ """
+ request.headers["Authorization"] = f"Bearer {self.bearer_token}"
+ yield request
diff --git a/pyDataverse/docs/source/reference.rst b/pyDataverse/docs/source/reference.rst
index d4787ef..5a76291 100644
--- a/pyDataverse/docs/source/reference.rst
+++ b/pyDataverse/docs/source/reference.rst
@@ -17,6 +17,7 @@ Access all of Dataverse APIs.
.. automodule:: pyDataverse.api
:members:
+ :special-members:
Models Interface
@@ -38,6 +39,13 @@ Helper functions.
:members:
+Auth Helpers
+-----------------------------
+
+.. automodule:: pyDataverse.auth
+ :members:
+
+
Exceptions
-----------------------------
diff --git a/pyproject.toml b/pyproject.toml
index 6c513fa..4e1e8de 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -44,6 +44,7 @@ optional = true
[tool.poetry.group.tests.dependencies]
pytest = "^8.1.1"
pytest-cov = "^5.0.0"
+pytest-asyncio = "^0.23.7"
tox = "^4.14.2"
selenium = "^4.19.0"
@@ -64,6 +65,7 @@ optional = true
black = "^24.3.0"
radon = "^6.0.1"
mypy = "^1.9.0"
+types-jsonschema = "^4.23.0"
autopep8 = "^2.1.0"
ruff = "^0.4.4"
diff --git a/tests/api/test_api.py b/tests/api/test_api.py
index f882127..817c4b3 100644
--- a/tests/api/test_api.py
+++ b/tests/api/test_api.py
@@ -1,8 +1,10 @@
import os
+import httpx
import pytest
from httpx import Response
from time import sleep
-from pyDataverse.api import NativeApi
+from pyDataverse.api import DataAccessApi, NativeApi, SwordApi
+from pyDataverse.auth import ApiTokenAuth
from pyDataverse.exceptions import ApiAuthorizationError
from pyDataverse.exceptions import ApiUrlError
from pyDataverse.models import Dataset
@@ -34,6 +36,36 @@ def test_api_connect_base_url_wrong(self):
NativeApi(None)
+class TestApiTokenAndAuthBehavior:
+ def test_api_token_none_and_auth_none(self):
+ api = NativeApi("https://demo.dataverse.org")
+ assert api.api_token is None
+ assert api.auth is None
+
+ def test_api_token_none_and_auth(self):
+ auth = ApiTokenAuth("mytoken")
+ api = NativeApi("https://demo.dataverse.org", auth=auth)
+ assert api.api_token is None
+ assert api.auth is auth
+
+ def test_api_token_and_auth(self):
+ auth = ApiTokenAuth("mytoken")
+ # Only one, api_token or auth, should be specified
+ with pytest.warns(UserWarning):
+ api = NativeApi(
+ "https://demo.dataverse.org", api_token="sometoken", auth=auth
+ )
+ assert api.api_token is None
+ assert api.auth is auth
+
+ def test_api_token_and_auth_none(self):
+ api_token = "mytoken"
+ api = NativeApi("https://demo.dataverse.org", api_token)
+ assert api.api_token == api_token
+ assert isinstance(api.auth, ApiTokenAuth)
+ assert api.auth.api_token == api_token
+
+
class TestApiRequests(object):
"""Test the native_api requests."""
@@ -150,3 +182,51 @@ def test_token_right_create_dataset_rights(self):
resp = api_su.delete_dataset(pid)
assert resp.json()["status"] == "OK"
+
+ def test_token_should_not_be_exposed_on_error(self):
+ BASE_URL = os.getenv("BASE_URL")
+ API_TOKEN = os.getenv("API_TOKEN")
+ api = DataAccessApi(BASE_URL, API_TOKEN)
+
+ result = api.get_datafile("does-not-exist").json()
+ assert API_TOKEN not in result["requestUrl"]
+
+ @pytest.mark.parametrize(
+ "auth", (True, False, "api-token", ApiTokenAuth("some-token"))
+ )
+ def test_using_auth_on_individual_requests_is_deprecated(self, auth):
+ BASE_URL = os.getenv("BASE_URL")
+ API_TOKEN = os.getenv("API_TOKEN")
+ api = DataAccessApi(BASE_URL, auth=ApiTokenAuth(API_TOKEN))
+ with pytest.warns(DeprecationWarning):
+ api.get_datafile("does-not-exist", auth=auth)
+
+ @pytest.mark.parametrize(
+ "auth", (True, False, "api-token", ApiTokenAuth("some-token"))
+ )
+ def test_using_auth_on_individual_requests_is_deprecated_unauthorized(
+ self, auth
+ ):
+ BASE_URL = os.getenv("BASE_URL")
+ no_auth_api = DataAccessApi(BASE_URL)
+ with pytest.warns(DeprecationWarning):
+ no_auth_api.get_datafile("does-not-exist", auth=auth)
+
+ def test_sword_api_requires_http_basic_auth(self):
+ BASE_URL = os.getenv("BASE_URL")
+ API_TOKEN = os.getenv("API_TOKEN")
+ api = SwordApi(BASE_URL, api_token=API_TOKEN)
+ assert isinstance(api.auth, httpx.BasicAuth)
+
+ def test_sword_api_can_authenticate(self):
+ BASE_URL = os.getenv("BASE_URL")
+ API_TOKEN = os.getenv("API_TOKEN")
+ api = SwordApi(BASE_URL, api_token=API_TOKEN)
+ response = api.get_service_document()
+ assert response.status_code == 200
+
+ def test_sword_api_cannot_authenticate_without_token(self):
+ BASE_URL = os.getenv("BASE_URL")
+ api = SwordApi(BASE_URL)
+ with pytest.raises(ApiAuthorizationError):
+ api.get_service_document()
diff --git a/tests/auth/__init__.py b/tests/auth/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/auth/test_auth.py b/tests/auth/test_auth.py
new file mode 100644
index 0000000..04265bf
--- /dev/null
+++ b/tests/auth/test_auth.py
@@ -0,0 +1,44 @@
+import uuid
+
+import pytest
+from httpx import Request
+
+from pyDataverse.auth import ApiTokenAuth, BearerTokenAuth
+from pyDataverse.exceptions import ApiAuthorizationError
+
+
+class TestApiTokenAuth:
+ def test_token_header_is_added_during_auth_flow(self):
+ api_token = str(uuid.uuid4())
+ auth = ApiTokenAuth(api_token)
+ request = Request("GET", "https://example.org")
+ assert "X-Dataverse-key" not in request.headers
+ modified_request = next(auth.auth_flow(request))
+ assert "X-Dataverse-key" in modified_request.headers
+ assert modified_request.headers["X-Dataverse-key"] == api_token
+
+ @pytest.mark.parametrize(
+ "non_str_token", (123, object(), lambda x: x, 1.423, b"123", uuid.uuid4())
+ )
+ def test_raise_if_token_is_not_str(self, non_str_token):
+ with pytest.raises(ApiAuthorizationError):
+ ApiTokenAuth(non_str_token)
+
+
+class TestBearerTokenAuth:
+ def test_authorization_header_is_added_during_auth_flow(self):
+ # Token as shown in RFC 6750
+ bearer_token = "mF_9.B5f-4.1JqM"
+ auth = BearerTokenAuth(bearer_token)
+ request = Request("GET", "https://example.org")
+ assert "Authorization" not in request.headers
+ modified_request = next(auth.auth_flow(request))
+ assert "Authorization" in modified_request.headers
+ assert modified_request.headers["Authorization"] == f"Bearer {bearer_token}"
+
+ @pytest.mark.parametrize(
+ "non_str_token", (123, object(), lambda x: x, 1.423, b"123", uuid.uuid4())
+ )
+ def test_raise_if_token_is_not_str(self, non_str_token):
+ with pytest.raises(ApiAuthorizationError):
+ BearerTokenAuth(non_str_token)