Skip to content

Commit

Permalink
Merge pull request #174 from gdcc/requests-via-httpx
Browse files Browse the repository at this point in the history
Requests via `httpx`
  • Loading branch information
JR-1991 authored Feb 22, 2024
2 parents c518477 + db89544 commit 4b8b5f3
Show file tree
Hide file tree
Showing 10 changed files with 301 additions and 20 deletions.
1 change: 0 additions & 1 deletion .github/workflows/test_build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ jobs:
strategy:
matrix:
python-version: [
"3.7",
"3.8",
"3.9",
"3.10",
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
[![PyPI](https://img.shields.io/pypi/v/pyDataverse.svg)](https://pypi.org/project/pyDataverse/) ![Build Status](https://github.com/gdcc/pyDataverse/actions/workflows/test_build.yml/badge.svg) [![Coverage Status](https://coveralls.io/repos/github/gdcc/pyDataverse/badge.svg)](https://coveralls.io/github/gdcc/pyDataverse) [![Documentation Status](https://readthedocs.org/projects/pydataverse/badge/?version=latest)](https://pydataverse.readthedocs.io/en/latest) ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/pydataverse.svg) [![GitHub](https://img.shields.io/github/license/gdcc/pydataverse.svg)](https://opensource.org/licenses/MIT) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.4664557.svg)](https://doi.org/10.5281/zenodo.4664557)
[![PyPI](https://img.shields.io/pypi/v/pyDataverse.svg)](https://pypi.org/project/pyDataverse/) ![Build Status](https://github.com/gdcc/pyDataverse/actions/workflows/test_build.yml/badge.svg) [![Coverage Status](https://coveralls.io/repos/github/gdcc/pyDataverse/badge.svg)](https://coveralls.io/github/gdcc/pyDataverse) [![Documentation Status](https://readthedocs.org/projects/pydataverse/badge/?version=latest)](https://pydataverse.readthedocs.io/en/latest) <img src="https://img.shields.io/badge/python-3.8 | 3.9 | 3.10 | 3.11-blue.svg" alt="PyPI - Python Version"> [![GitHub](https://img.shields.io/github/license/gdcc/pydataverse.svg)](https://opensource.org/licenses/MIT) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.4664557.svg)](https://doi.org/10.5281/zenodo.4664557)

# pyDataverse

Expand Down
2 changes: 1 addition & 1 deletion requirements/common.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
requests>=2.12.0
httpx>=0.26.0
jsonschema>=3.2.0
7 changes: 5 additions & 2 deletions setup.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Find out more at https://github.com/GDCC/pyDataverse."""

import codecs
import os
import re
Expand All @@ -21,7 +22,9 @@ def find_version(*file_paths):
"""Find package version from file."""
version_file = read_file(*file_paths)
version_match = re.search(
r"^__version__ = ['\"]([^'\"]*)['\"]", version_file, re.M,
r"^__version__ = ['\"]([^'\"]*)['\"]",
version_file,
re.M,
)
if version_match:
return version_match.group(1)
Expand Down Expand Up @@ -50,7 +53,7 @@ def run_tests(self):
INSTALL_REQUIREMENTS = [
# A string or list of strings specifying what other distributions need to
# be installed when this one is.
"requests>=2.12.0",
"httpx>=0.26.0",
"jsonschema>=3.2.0",
]

Expand Down
3 changes: 2 additions & 1 deletion src/pyDataverse/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@
Licensed under the MIT License.
"""

from __future__ import absolute_import

from requests.packages import urllib3
import urllib3

urllib3.disable_warnings() # noqa

Expand Down
28 changes: 15 additions & 13 deletions src/pyDataverse/api.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
"""Dataverse API wrapper for all it's API's."""

import json
import httpx
import subprocess as sp
from urllib.parse import urljoin

from requests import ConnectionError, Response, delete, get, post, put
from httpx import ConnectError, Response

from pyDataverse.exceptions import (
ApiAuthorizationError,
Expand Down Expand Up @@ -120,7 +122,7 @@ def get_request(self, url, params=None, auth=False):

try:
url = urljoin(self.base_url_api, url)
resp = get(url, params=params)
resp = httpx.get(url, params=params)
if resp.status_code == 401:
error_msg = resp.json()["message"]
raise ApiAuthorizationError(
Expand All @@ -137,8 +139,8 @@ def get_request(self, url, params=None, auth=False):
)
)
return resp
except ConnectionError:
raise ConnectionError(
except ConnectError:
raise ConnectError(
"ERROR: GET - Could not establish connection to api {0}.".format(url)
)

Expand Down Expand Up @@ -173,7 +175,7 @@ def post_request(self, url, data=None, auth=False, params=None, files=None):
params["key"] = self.api_token

try:
resp = post(url, data=data, params=params, files=files)
resp = httpx.post(url, data=data, params=params, files=files)
if resp.status_code == 401:
error_msg = resp.json()["message"]
raise ApiAuthorizationError(
Expand All @@ -182,8 +184,8 @@ def post_request(self, url, data=None, auth=False, params=None, files=None):
)
)
return resp
except ConnectionError:
raise ConnectionError(
except ConnectError:
raise ConnectError(
"ERROR: POST - Could not establish connection to API: {0}".format(url)
)

Expand Down Expand Up @@ -214,7 +216,7 @@ def put_request(self, url, data=None, auth=False, params=None):
params["key"] = self.api_token

try:
resp = put(url, data=data, params=params)
resp = httpx.put(url, data=data, params=params)
if resp.status_code == 401:
error_msg = resp.json()["message"]
raise ApiAuthorizationError(
Expand All @@ -223,8 +225,8 @@ def put_request(self, url, data=None, auth=False, params=None):
)
)
return resp
except ConnectionError:
raise ConnectionError(
except ConnectError:
raise ConnectError(
"ERROR: PUT - Could not establish connection to api '{0}'.".format(url)
)

Expand Down Expand Up @@ -253,9 +255,9 @@ def delete_request(self, url, auth=False, params=None):
params["key"] = self.api_token

try:
return delete(url, params=params)
except ConnectionError:
raise ConnectionError(
return httpx.delete(url, params=params)
except ConnectError:
raise ConnectError(
"ERROR: DELETE could not establish connection to api {}.".format(url)
)

Expand Down
2 changes: 1 addition & 1 deletion tests/api/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import json
import os
import pytest
from requests import Response
from httpx import Response
from time import sleep
from pyDataverse.api import NativeApi
from pyDataverse.exceptions import ApiAuthorizationError
Expand Down
198 changes: 198 additions & 0 deletions tests/api/test_upload.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import json
import os
import tempfile

import httpx

from pyDataverse.api import NativeApi
from pyDataverse.models import Datafile


class TestFileUpload:

def test_file_upload(self):
"""
Test case for uploading a file to a dataset.
This test case performs the following steps:
1. Creates a dataset using the provided metadata.
2. Prepares a file for upload.
3. Uploads the file to the dataset.
4. Asserts that the file upload was successful.
Raises:
AssertionError: If the file upload fails.
"""
# Arrange
BASE_URL = os.getenv("BASE_URL").rstrip("/")
API_TOKEN = os.getenv("API_TOKEN")

# Create dataset
metadata = json.load(open("tests/data/file_upload_ds_minimum.json"))
pid = self._create_dataset(BASE_URL, API_TOKEN, metadata)
api = NativeApi(BASE_URL, API_TOKEN)

# Prepare file upload
df = Datafile({"pid": pid, "filename": "datafile.txt"})

# Act
response = api.upload_datafile(
identifier=pid,
filename="tests/data/datafile.txt",
json_str=df.json(),
)

# Assert
assert response.status_code == 200, "File upload failed."

def test_file_replacement(self):
"""
Test case for replacing a file in a dataset.
Steps:
1. Create a dataset using the provided metadata.
2. Upload a datafile to the dataset.
3. Replace the uploaded datafile with a mutated version.
4. Verify that the file replacement was successful and the content matches the expected content.
"""

# Arrange
BASE_URL = os.getenv("BASE_URL").rstrip("/")
API_TOKEN = os.getenv("API_TOKEN")

# Create dataset
metadata = json.load(open("tests/data/file_upload_ds_minimum.json"))
pid = self._create_dataset(BASE_URL, API_TOKEN, metadata)
api = NativeApi(BASE_URL, API_TOKEN)

# Perform file upload
df = Datafile({"pid": pid, "filename": "datafile.txt"})
response = api.upload_datafile(
identifier=pid,
filename="tests/data/replace.xyz",
json_str=df.json(),
)

# Retrieve file ID
file_id = self._get_file_id(BASE_URL, API_TOKEN, pid)

# Act
with tempfile.TemporaryDirectory() as tempdir:

orginal = open("tests/data/replace.xyz").read()
mutated = "Z" + orginal[1::]
mutated_path = os.path.join(tempdir, "replace.xyz")

with open(mutated_path, "w") as f:
f.write(mutated)

json_data = {
"description": "My description.",
"categories": ["Data"],
"forceReplace": False,
}

response = api.replace_datafile(
identifier=file_id,
filename=mutated_path,
json_str=json.dumps(json_data),
is_filepid=False,
)

# Assert
replaced_id = self._get_file_id(BASE_URL, API_TOKEN, pid)
replaced_content = self._fetch_datafile_content(
BASE_URL,
API_TOKEN,
replaced_id,
)

assert response.status_code == 200, "File replacement failed."
assert (
replaced_content == mutated
), "File content does not match the expected content."

@staticmethod
def _create_dataset(
BASE_URL: str,
API_TOKEN: str,
metadata: dict,
):
"""
Create a dataset in the Dataverse.
Args:
BASE_URL (str): The base URL of the Dataverse instance.
API_TOKEN (str): The API token for authentication.
metadata (dict): The metadata for the dataset.
Returns:
str: The persistent identifier (PID) of the created dataset.
"""
url = f"{BASE_URL}/api/dataverses/root/datasets"
response = httpx.post(
url=url,
json=metadata,
headers={
"X-Dataverse-key": API_TOKEN,
"Content-Type": "application/json",
},
)

response.raise_for_status()

return response.json()["data"]["persistentId"]

@staticmethod
def _get_file_id(
BASE_URL: str,
API_TOKEN: str,
pid: str,
):
"""
Retrieves the file ID for a given persistent identifier (PID) in Dataverse.
Args:
BASE_URL (str): The base URL of the Dataverse instance.
API_TOKEN (str): The API token for authentication.
pid (str): The persistent identifier (PID) of the dataset.
Returns:
str: The file ID of the latest version of the dataset.
Raises:
HTTPError: If the HTTP request to retrieve the file ID fails.
"""
response = httpx.get(
url=f"{BASE_URL}/api/datasets/:persistentId/?persistentId={pid}",
headers={"X-Dataverse-key": API_TOKEN},
)

response.raise_for_status()

return response.json()["data"]["latestVersion"]["files"][0]["dataFile"]["id"]

@staticmethod
def _fetch_datafile_content(
BASE_URL: str,
API_TOKEN: str,
id: str,
):
"""
Fetches the content of a datafile from the specified BASE_URL using the provided API_TOKEN.
Args:
BASE_URL (str): The base URL of the Dataverse instance.
API_TOKEN (str): The API token for authentication.
id (str): The ID of the datafile.
Returns:
str: The content of the datafile as a decoded UTF-8 string.
"""
url = f"{BASE_URL}/api/access/datafile/{id}"
headers = {"X-Dataverse-key": API_TOKEN}
response = httpx.get(url, headers=headers)
response.raise_for_status()

return response.content.decode("utf-8")
Loading

0 comments on commit 4b8b5f3

Please sign in to comment.