From 35a119a25bbddf55e60e6514387939d564681435 Mon Sep 17 00:00:00 2001 From: Jan Range <30547301+JR-1991@users.noreply.github.com> Date: Tue, 18 Feb 2025 09:31:19 +0100 Subject: [PATCH 01/19] add `tabIngest` field --- README.md | 1 + dvuploader/file.py | 1 + 2 files changed, 2 insertions(+) diff --git a/README.md b/README.md index 049d8c5..4886709 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,7 @@ Alternatively, you can also supply a `config` file that contains all necessary i * `mimetype`: Mimetype of the file. * `categories`: Optional list of categories to assign to the file. * `restrict`: Boolean to indicate that this is a restricted file. Defaults to False. + * `tabIngest`: Boolean to indicate that the file should be ingested as a tab-separated file. Defaults to True. In the following example, we upload three files to a Dataverse instance. The first file is uploaded to the root directory of the dataset, while the other two files are uploaded to the directory `some/dir`. diff --git a/dvuploader/file.py b/dvuploader/file.py index 8a9bb44..4bc37ce 100644 --- a/dvuploader/file.py +++ b/dvuploader/file.py @@ -54,6 +54,7 @@ class File(BaseModel): checksum: Optional[Checksum] = None to_replace: bool = False file_id: Optional[Union[str, int]] = Field(default=None, alias="fileToReplaceId") + tab_ingest: bool = Field(default=True, alias="tabIngest") _size: int = PrivateAttr(default=0) From d149826c98111033ccc25be68e81d721a06f53f4 Mon Sep 17 00:00:00 2001 From: Jan Range <30547301+JR-1991@users.noreply.github.com> Date: Tue, 18 Feb 2025 09:41:17 +0100 Subject: [PATCH 02/19] add troubleshooting --- README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.md b/README.md index 4886709..029816f 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,20 @@ The `config` file can then be used as follows: dvuploader --config-path config.yml ``` +## Troubleshooting + +#### `500` error and `OptimisticLockException` + +When uploading multiple tabular files, you might encounter a `500` error and a `OptimisticLockException` upon the file registration step. This has been discussed in https://github.com/IQSS/dataverse/issues/11265 and is due to the fact that intermediate locks prevent the file registration step from completing. + +A workaround is to set the `tabIngest` flag to `False` for all files that are to be uploaded. This will cause the files to be uploaded in the native format of the dataverse instance and avoid the intermediate locks. + +```python +dv.File(filepath="hallo.csv", tab_ingest=False) +``` + +Please be aware that your tabular files will not be ingested as such but will be uploaded in their native format. You can utilize [pyDataverse](https://github.com/gdcc/pyDataverse/blob/693d0ff8d2849eccc32f9e66228ee8976109881a/pyDataverse/api.py#L2475) to ingest the files after they have been uploaded. + ## Development To install the development dependencies, run the following command: From ed6636a27bc6faa23049ac3598c3b5f12778b66b Mon Sep 17 00:00:00 2001 From: Jan Range <30547301+JR-1991@users.noreply.github.com> Date: Tue, 18 Feb 2025 09:58:42 +0100 Subject: [PATCH 03/19] change wording --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 029816f..f071ac0 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ dvuploader --config-path config.yml When uploading multiple tabular files, you might encounter a `500` error and a `OptimisticLockException` upon the file registration step. This has been discussed in https://github.com/IQSS/dataverse/issues/11265 and is due to the fact that intermediate locks prevent the file registration step from completing. -A workaround is to set the `tabIngest` flag to `False` for all files that are to be uploaded. This will cause the files to be uploaded in the native format of the dataverse instance and avoid the intermediate locks. +A workaround is to set the `tabIngest` flag to `False` for all files that are to be uploaded. This will cause the files not be ingested but will avoid the intermediate locks. ```python dv.File(filepath="hallo.csv", tab_ingest=False) From 9d6dbe71539b650d759e3d1fb7189058b601613b Mon Sep 17 00:00:00 2001 From: Jan Range <30547301+JR-1991@users.noreply.github.com> Date: Wed, 19 Feb 2025 16:07:46 +0100 Subject: [PATCH 04/19] only register if data exists --- dvuploader/directupload.py | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/dvuploader/directupload.py b/dvuploader/directupload.py index c2b84b3..dcfadd3 100644 --- a/dvuploader/directupload.py +++ b/dvuploader/directupload.py @@ -554,17 +554,21 @@ async def _add_files_to_ds( novel_json_data = _prepare_registration(files, use_replace=False) replace_json_data = _prepare_registration(files, use_replace=True) - await _multipart_json_data_request( - session=session, - json_data=novel_json_data, - url=novel_url, - ) + if novel_json_data: + # Register new files, if any + await _multipart_json_data_request( + session=session, + json_data=novel_json_data, + url=novel_url, + ) - await _multipart_json_data_request( - session=session, - json_data=replace_json_data, - url=replace_url, - ) + if replace_json_data: + # Register replacement files, if any + await _multipart_json_data_request( + session=session, + json_data=replace_json_data, + url=replace_url, + ) progress.update(pbar, advance=1) From e74fddc6785cecfe54ba48b87983940c00656c71 Mon Sep 17 00:00:00 2001 From: Jan Range <30547301+JR-1991@users.noreply.github.com> Date: Wed, 19 Feb 2025 16:17:14 +0100 Subject: [PATCH 05/19] test all replace and add cases * `replaceFiles` is not called when there are no files to replace * `addFiles` is not called when there are no files to replace * Both are called when there exist both new and replacement files --- tests/unit/test_directupload.py | 63 +++++++++++++++++++++++++++++++-- 1 file changed, 61 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_directupload.py b/tests/unit/test_directupload.py index a2c2d93..0371a1c 100644 --- a/tests/unit/test_directupload.py +++ b/tests/unit/test_directupload.py @@ -12,13 +12,35 @@ class Test_AddFileToDs: # Should successfully add files to a Dataverse dataset with a valid file path @pytest.mark.asyncio - async def test_successfully_add_replace_file_with_valid_filepath(self, httpx_mock): + async def test_successfully_add_file_with_valid_filepath(self, httpx_mock): # Mock the session.post method to return a response with status code 200 httpx_mock.add_response( method="post", url="https://example.com/api/datasets/:persistentId/addFiles?persistentId=pid", ) + # Initialize the necessary variables + session = httpx.AsyncClient() + dataverse_url = "https://example.com" + pid = "pid" + fpath = "tests/fixtures/add_dir_files/somefile.txt" + files = [File(filepath=fpath)] + progress = Progress() + pbar = progress.add_task("Uploading", total=1) + + # Invoke the function + await _add_files_to_ds( + session=session, + dataverse_url=dataverse_url, + pid=pid, + files=files, + progress=progress, + pbar=pbar, + ) + + @pytest.mark.asyncio + async def test_successfully_replace_file_with_valid_filepath(self, httpx_mock): + # Mock the session.post method to return a response with status code 200 httpx_mock.add_response( method="post", url="https://example.com/api/datasets/:persistentId/replaceFiles?persistentId=pid", @@ -29,7 +51,44 @@ async def test_successfully_add_replace_file_with_valid_filepath(self, httpx_moc dataverse_url = "https://example.com" pid = "pid" fpath = "tests/fixtures/add_dir_files/somefile.txt" - files = [File(filepath=fpath)] + files = [File(filepath=fpath, to_replace=True)] + progress = Progress() + pbar = progress.add_task("Uploading", total=1) + + # Invoke the function + await _add_files_to_ds( + session=session, + dataverse_url=dataverse_url, + pid=pid, + files=files, + progress=progress, + pbar=pbar, + ) + + @pytest.mark.asyncio + async def test_successfully_add_and_replace_file_with_valid_filepath( + self, httpx_mock + ): + # Mock the session.post method to return a response with status code 200 + httpx_mock.add_response( + method="post", + url="https://example.com/api/datasets/:persistentId/replaceFiles?persistentId=pid", + ) + + httpx_mock.add_response( + method="post", + url="https://example.com/api/datasets/:persistentId/addFiles?persistentId=pid", + ) + + # Initialize the necessary variables + session = httpx.AsyncClient() + dataverse_url = "https://example.com" + pid = "pid" + fpath = "tests/fixtures/add_dir_files/somefile.txt" + files = [ + File(filepath=fpath, to_replace=True), + File(filepath=fpath), + ] progress = Progress() pbar = progress.add_task("Uploading", total=1) From d75e24337941baefed5de07681b3d126c33fa7b5 Mon Sep 17 00:00:00 2001 From: Jan Range <30547301+JR-1991@users.noreply.github.com> Date: Wed, 26 Feb 2025 14:28:34 +0100 Subject: [PATCH 06/19] add `config` to control retries This is added to provide control structures to avoid dataset locks. --- README.md | 43 ++++++++++++++++++- dvuploader/__init__.py | 1 + dvuploader/config.py | 56 +++++++++++++++++++++++++ dvuploader/nativeupload.py | 1 - tests/conftest.py | 33 +++++++++++++++ tests/integration/test_native_upload.py | 43 ++++++++++++++++++- 6 files changed, 174 insertions(+), 3 deletions(-) create mode 100644 dvuploader/config.py diff --git a/README.md b/README.md index f071ac0..5d78e2d 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,47 @@ The `config` file can then be used as follows: dvuploader --config-path config.yml ``` +### Environment variables + +DVUploader provides several environment variables that allow you to control retry logic and upload size limits. These can be set either through environment variables directly or programmatically using the `config` function. + +**Available Environment Variables:** +- `DVUPLOADER_MAX_RETRIES`: Maximum number of retry attempts (default: 15) +- `DVUPLOADER_MAX_RETRY_TIME`: Maximum wait time between retries in seconds (default: 240) +- `DVUPLOADER_MIN_RETRY_TIME`: Minimum wait time between retries in seconds (default: 1) +- `DVUPLOADER_RETRY_MULTIPLIER`: Multiplier for exponential backoff (default: 0.1) +- `DVUPLOADER_MAX_PKG_SIZE`: Maximum package size in bytes (default: 2GB) + +**Setting via environment:** +```bash +export DVUPLOADER_MAX_RETRIES=20 +export DVUPLOADER_MAX_RETRY_TIME=300 +export DVUPLOADER_MIN_RETRY_TIME=2 +export DVUPLOADER_RETRY_MULTIPLIER=0.2 +export DVUPLOADER_MAX_PKG_SIZE=3221225472 # 3GB +``` + +**Setting programmatically:** +```python +import dvuploader as dv + +# Configure the uploader settings +dv.config( + max_retries=20, + max_retry_time=300, + min_retry_time=2, + retry_multiplier=0.2, + max_package_size=3 * 1024**3 # 3GB +) + +# Continue with your upload as normal +files = [dv.File(filepath="./data.csv")] +dvuploader = dv.DVUploader(files=files) +# ... rest of your upload code +``` + +The retry logic uses exponential backoff which ensures that subsequent retries will be longer, but wont exceed exceed `max_retry_time`. This is particularly useful when dealing with native uploads that may be subject to intermediate locks on the Dataverse side. + ## Troubleshooting #### `500` error and `OptimisticLockException` @@ -125,7 +166,7 @@ When uploading multiple tabular files, you might encounter a `500` error and a ` A workaround is to set the `tabIngest` flag to `False` for all files that are to be uploaded. This will cause the files not be ingested but will avoid the intermediate locks. ```python -dv.File(filepath="hallo.csv", tab_ingest=False) +dv.File(filepath="tab_file.csv", tab_ingest=False) ``` Please be aware that your tabular files will not be ingested as such but will be uploaded in their native format. You can utilize [pyDataverse](https://github.com/gdcc/pyDataverse/blob/693d0ff8d2849eccc32f9e66228ee8976109881a/pyDataverse/api.py#L2475) to ingest the files after they have been uploaded. diff --git a/dvuploader/__init__.py b/dvuploader/__init__.py index d357e67..fb8970f 100644 --- a/dvuploader/__init__.py +++ b/dvuploader/__init__.py @@ -1,6 +1,7 @@ from .dvuploader import DVUploader # noqa: F401 from .file import File # noqa: F401 from .utils import add_directory # noqa: F401 +from .config import config # noqa: F401 import nest_asyncio diff --git a/dvuploader/config.py b/dvuploader/config.py new file mode 100644 index 0000000..4203782 --- /dev/null +++ b/dvuploader/config.py @@ -0,0 +1,56 @@ +import os + + +def config( + max_retries: int = 15, + max_retry_time: int = 240, + min_retry_time: int = 1, + retry_multiplier: float = 0.1, + max_package_size: int = 2 * 1024**3, +): + """This function sets the environment variables for the dvuploader package. + + Use this function to set the environment variables for the dvuploader package, + which controls the behavior of the package. This is particularly useful when + you want to be more loose on the handling of the retry logic and upload size. + + Retry logic: + Native uploads in particular may be subject to intermediate locks + on the Dataverse side, which may cause the upload to fail. We provide + and exponential backoff mechanism to deal with this. + + The exponential backoff is controlled by the following environment variables: + - DVUPLOADER_MAX_RETRIES: The maximum number of retries. + - DVUPLOADER_MAX_RETRY_TIME: The maximum retry time. + - DVUPLOADER_MIN_RETRY_TIME: The minimum retry time. + - DVUPLOADER_RETRY_MULTIPLIER: The retry multiplier. + + The recursive formula for the wait time is: + wait_time = min_retry_time * retry_multiplier^n + where n is the number of retries. + + The wait time will not exceed max_retry_time. + + Upload size: + The maximum upload size is controlled by the following environment variable: + - DVUPLOADER_MAX_PKG_SIZE: The maximum package size. + + The default maximum package size is 2GB, but this can be changed by + setting the DVUPLOADER_MAX_PKG_SIZE environment variable. + + We recommend not to exceed 2GB, as this is the maximum size supported + by Dataverse and beyond that the risk of failure increases. + + Args: + max_retries (int): The maximum number of retries. + max_retry_time (int): The maximum retry time. + min_retry_time (int): The minimum retry time. + retry_multiplier (float): The retry multiplier. + max_package_size (int): The maximum package size. + """ + + os.environ["DVUPLOADER_MAX_RETRIES"] = str(max_retries) + os.environ["DVUPLOADER_MAX_RETRY_TIME"] = str(max_retry_time) + os.environ["DVUPLOADER_MIN_RETRY_TIME"] = str(min_retry_time) + os.environ["DVUPLOADER_RETRY_MULTIPLIER"] = str(retry_multiplier) + os.environ["DVUPLOADER_MAX_PKG_SIZE"] = str(max_package_size) diff --git a/dvuploader/nativeupload.py b/dvuploader/nativeupload.py index dc0292b..2d4431b 100644 --- a/dvuploader/nativeupload.py +++ b/dvuploader/nativeupload.py @@ -236,7 +236,6 @@ async def _single_native_upload( # Wait to avoid rate limiting await asyncio.sleep(1.0) - return False, {"message": "Failed to upload file"} diff --git a/tests/conftest.py b/tests/conftest.py index 2590812..0a51f56 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,8 @@ import os import pytest import httpx +import random +import pandas as pd @pytest.fixture @@ -67,3 +69,34 @@ def create_mock_file( f.write(b"\0") return path + + +def create_mock_tabular_file( + directory: str, + name: str, + rows: int = 1000000, + cols: int = 10, +): + """Create a tabular file with the specified number of rows and columns. + + Args: + directory (str): The directory where the file will be created. + name (str): The name of the file. + rows (int, optional): The number of rows in the file. Defaults to 1000000. + cols (int, optional): The number of columns in the file. Defaults to 10. + """ + path = os.path.join(directory, name) + with open(path, "w") as f: + # Create header + f.write(",".join([f"col_{i}" for i in range(cols)]) + "\n") + + # Create rows + for i in range(rows): + f.write( + f"{i}" + + "," + + ",".join([f"{random.randint(0, 100)}" for j in range(cols - 1)]) + + "\n" + ) + + return path diff --git a/tests/integration/test_native_upload.py b/tests/integration/test_native_upload.py index 821b4fa..5519ed1 100644 --- a/tests/integration/test_native_upload.py +++ b/tests/integration/test_native_upload.py @@ -6,7 +6,7 @@ from dvuploader.file import File from dvuploader.utils import add_directory, retrieve_dataset_files -from tests.conftest import create_dataset, create_mock_file +from tests.conftest import create_dataset, create_mock_file, create_mock_tabular_file class TestNativeUpload: @@ -170,3 +170,44 @@ def test_native_upload_by_handler( assert file["description"] == "This is a test", ( f"Description does not match for file {json.dumps(file)}" ) + + def test_native_upload_with_large_tabular_files( + self, + credentials, + ): + BASE_URL, API_TOKEN = credentials + + # Create Dataset + pid = create_dataset( + parent="Root", + server_url=BASE_URL, + api_token=API_TOKEN, + ) + + # We are uploading large tabular files in a loop to test the uploader's + # ability to wait for locks to be released. + # + # The uploader should wait for the lock to be released and then upload + # the file. + # + with tempfile.TemporaryDirectory() as directory: + for i in range(10): + # Arrange + path = create_mock_tabular_file( + directory, + f"large_tabular_file_{i}.csv", + rows=100000, + cols=20, + ) + + # Add all files in the directory + files = [File(filepath=path)] + + # Act + uploader = DVUploader(files=files) + uploader.upload( + persistent_id=pid, + api_token=API_TOKEN, + dataverse_url=BASE_URL, + n_parallel_uploads=1, + ) From 1a46eaf9cb9c2a839978ee0faefe6ad195eaa13a Mon Sep 17 00:00:00 2001 From: Jan Range <30547301+JR-1991@users.noreply.github.com> Date: Wed, 26 Feb 2025 14:29:18 +0100 Subject: [PATCH 07/19] set retry strategy to exp and tab file checks --- dvuploader/nativeupload.py | 67 +++++++++++++++++++++++++++++++++++--- 1 file changed, 62 insertions(+), 5 deletions(-) diff --git a/dvuploader/nativeupload.py b/dvuploader/nativeupload.py index 2d4431b..f04c6b3 100644 --- a/dvuploader/nativeupload.py +++ b/dvuploader/nativeupload.py @@ -1,5 +1,6 @@ import asyncio from io import BytesIO +from pathlib import Path import httpx import json import os @@ -13,12 +14,40 @@ from dvuploader.packaging import distribute_files, zip_files from dvuploader.utils import build_url, retrieve_dataset_files +##### CONFIGURATION ##### + +# Based on MAX_RETRIES, we will wait between 0.3 and 120 seconds between retries: +# Exponential recursion: 0.1 * 2^n +# +# This will exponentially increase the wait time between retries. +# The max wait time is 240 seconds per retry though. MAX_RETRIES = int(os.environ.get("DVUPLOADER_MAX_RETRIES", 15)) +MAX_RETRY_TIME = int(os.environ.get("DVUPLOADER_MAX_RETRY_TIME", 240)) +MIN_RETRY_TIME = int(os.environ.get("DVUPLOADER_MIN_RETRY_TIME", 1)) +RETRY_MULTIPLIER = float(os.environ.get("DVUPLOADER_RETRY_MULTIPLIER", 0.1)) +RETRY_STRAT = tenacity.wait_exponential( + multiplier=RETRY_MULTIPLIER, + min=MIN_RETRY_TIME, + max=MAX_RETRY_TIME, +) + +assert isinstance(MAX_RETRIES, int), "DVUPLOADER_MAX_RETRIES must be an integer" +assert isinstance(MAX_RETRY_TIME, int), "DVUPLOADER_MAX_RETRY_TIME must be an integer" +assert isinstance(MIN_RETRY_TIME, int), "DVUPLOADER_MIN_RETRY_TIME must be an integer" +assert isinstance(RETRY_MULTIPLIER, float), ( + "DVUPLOADER_RETRY_MULTIPLIER must be a float" +) + +##### END CONFIGURATION ##### + NATIVE_UPLOAD_ENDPOINT = "/api/datasets/:persistentId/add" NATIVE_REPLACE_ENDPOINT = "/api/files/{FILE_ID}/replace" NATIVE_METADATA_ENDPOINT = "/api/files/{FILE_ID}/metadata" -assert isinstance(MAX_RETRIES, int), "DVUPLOADER_MAX_RETRIES must be an integer" +TABULAR_EXTENSIONS = [ + "csv", + "tsv", +] async def native_upload( @@ -172,7 +201,7 @@ def _reset_progress( @tenacity.retry( - wait=tenacity.wait_fixed(0.5), + wait=RETRY_STRAT, stop=tenacity.stop_after_attempt(MAX_RETRIES), ) async def _single_native_upload( @@ -211,7 +240,11 @@ async def _single_native_upload( json_data = _get_json_data(file) files = { - "file": (file.file_name, file.handler, file.mimeType), + "file": ( + file.file_name, + file.handler, + file.mimeType, + ), "jsonData": ( None, BytesIO(json.dumps(json_data).encode()), @@ -291,12 +324,16 @@ async def _update_metadata( dv_path = os.path.join(file.directory_label, file.file_name) # type: ignore try: + if _is_tabular(file): + dv_path = _tab_extension(dv_path) + print("TABULAR", dv_path) + file_id = file_mapping[dv_path] except KeyError: raise ValueError( ( f"File {dv_path} not found in Dataverse repository.", - "This may be due to the file not being uploaded to the repository.", + "This may be due to the file not being uploaded to the repository:", ) ) @@ -312,7 +349,7 @@ async def _update_metadata( @tenacity.retry( - wait=tenacity.wait_fixed(0.3), + wait=RETRY_STRAT, stop=tenacity.stop_after_attempt(MAX_RETRIES), ) async def _update_single_metadata( @@ -404,3 +441,23 @@ def _create_file_id_path_mapping(files): mapping[path] = file["id"] return mapping + + +def _tab_extension(path: str) -> str: + """ + Adds a tabular extension to the path if it is not already present. + """ + return str(Path(path).with_suffix(".tab")) + + +def _is_tabular(file: File) -> bool: + """ + Checks if a file is a tabular file. + """ + is_tabular = False + + for extension in TABULAR_EXTENSIONS: + if file.file_name and file.file_name.endswith(extension): + is_tabular = True + break + return is_tabular From 8567fce134b1c86a49e89fed62ac23173b98d411 Mon Sep 17 00:00:00 2001 From: Jan Range <30547301+JR-1991@users.noreply.github.com> Date: Wed, 26 Feb 2025 14:38:28 +0100 Subject: [PATCH 08/19] fix spelling --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5d78e2d..3bc8ec5 100644 --- a/README.md +++ b/README.md @@ -155,7 +155,7 @@ dvuploader = dv.DVUploader(files=files) # ... rest of your upload code ``` -The retry logic uses exponential backoff which ensures that subsequent retries will be longer, but wont exceed exceed `max_retry_time`. This is particularly useful when dealing with native uploads that may be subject to intermediate locks on the Dataverse side. +The retry logic uses exponential backoff which ensures that subsequent retries will be longer, but won't exceed exceed `max_retry_time`. This is particularly useful when dealing with native uploads that may be subject to intermediate locks on the Dataverse side. ## Troubleshooting From 80db50269df6a0286be9826d574444dc0159b80d Mon Sep 17 00:00:00 2001 From: Jan Range <30547301+JR-1991@users.noreply.github.com> Date: Wed, 26 Feb 2025 14:38:33 +0100 Subject: [PATCH 09/19] remove pandas dep --- tests/conftest.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 0a51f56..84e5cb2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,7 +2,6 @@ import pytest import httpx import random -import pandas as pd @pytest.fixture From 2ff10e93b57f053877cd0cc1987714bb33b96463 Mon Sep 17 00:00:00 2001 From: Jan Range <30547301+JR-1991@users.noreply.github.com> Date: Tue, 18 Mar 2025 23:12:51 +0100 Subject: [PATCH 10/19] update `httpx` minor version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a3bb91c..a337c28 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ readme = "README.md" [tool.poetry.dependencies] python = "^3.9" pydantic = "^2.5.3" -httpx = "^0.27.0" +httpx = "^0.28" typer = "^0.9.0" pyyaml = "^6.0.1" nest-asyncio = "^1.5.8" From d09c5b75541fd17f833958bad583bf0ac0682ddb Mon Sep 17 00:00:00 2001 From: Jan Range <30547301+JR-1991@users.noreply.github.com> Date: Tue, 18 Mar 2025 23:13:23 +0100 Subject: [PATCH 11/19] use generic `octet-stream` for mime detection --- dvuploader/file.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dvuploader/file.py b/dvuploader/file.py index 4bc37ce..dbd7c7a 100644 --- a/dvuploader/file.py +++ b/dvuploader/file.py @@ -45,7 +45,7 @@ class File(BaseModel): handler: Union[BytesIO, StringIO, IO, None] = Field(default=None, exclude=True) description: str = "" directory_label: str = Field(default="", alias="directoryLabel") - mimeType: str = "text/plain" + mimeType: str = "application/octet-stream" categories: List[str] = ["DATA"] restrict: bool = False checksum_type: ChecksumTypes = Field(default=ChecksumTypes.MD5, exclude=True) From 5209eb2b43b813db2dec07bbe8d9d73d5e85cac9 Mon Sep 17 00:00:00 2001 From: Jan Range <30547301+JR-1991@users.noreply.github.com> Date: Tue, 18 Mar 2025 23:13:38 +0100 Subject: [PATCH 12/19] check for tabular files --- dvuploader/nativeupload.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/dvuploader/nativeupload.py b/dvuploader/nativeupload.py index f04c6b3..cc1c783 100644 --- a/dvuploader/nativeupload.py +++ b/dvuploader/nativeupload.py @@ -22,7 +22,7 @@ # This will exponentially increase the wait time between retries. # The max wait time is 240 seconds per retry though. MAX_RETRIES = int(os.environ.get("DVUPLOADER_MAX_RETRIES", 15)) -MAX_RETRY_TIME = int(os.environ.get("DVUPLOADER_MAX_RETRY_TIME", 240)) +MAX_RETRY_TIME = int(os.environ.get("DVUPLOADER_MAX_RETRY_TIME", 60)) MIN_RETRY_TIME = int(os.environ.get("DVUPLOADER_MIN_RETRY_TIME", 1)) RETRY_MULTIPLIER = float(os.environ.get("DVUPLOADER_RETRY_MULTIPLIER", 0.1)) RETRY_STRAT = tenacity.wait_exponential( @@ -324,11 +324,10 @@ async def _update_metadata( dv_path = os.path.join(file.directory_label, file.file_name) # type: ignore try: - if _is_tabular(file): - dv_path = _tab_extension(dv_path) - print("TABULAR", dv_path) - - file_id = file_mapping[dv_path] + if _tab_extension(dv_path) in file_mapping: + file_id = file_mapping[_tab_extension(dv_path)] + else: + file_id = file_mapping[dv_path] except KeyError: raise ValueError( ( From 0d522bbc90d385afb498ee8720abbd0a30f540f2 Mon Sep 17 00:00:00 2001 From: Jan Range <30547301+JR-1991@users.noreply.github.com> Date: Tue, 18 Mar 2025 23:13:48 +0100 Subject: [PATCH 13/19] reduce number of rows --- tests/integration/test_native_upload.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/test_native_upload.py b/tests/integration/test_native_upload.py index 5519ed1..93e5346 100644 --- a/tests/integration/test_native_upload.py +++ b/tests/integration/test_native_upload.py @@ -196,7 +196,7 @@ def test_native_upload_with_large_tabular_files( path = create_mock_tabular_file( directory, f"large_tabular_file_{i}.csv", - rows=100000, + rows=10000, cols=20, ) From 8641c6d0ad6477078903cfbddba769c793669746 Mon Sep 17 00:00:00 2001 From: Jan Range <30547301+JR-1991@users.noreply.github.com> Date: Tue, 18 Mar 2025 23:20:01 +0100 Subject: [PATCH 14/19] update `pytest-httpx` dep to resolve dep issues --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a337c28..e89c195 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ codespell = "^2.2.6" coverage = "^7.4.0" pytest-cov = "^4.1.0" pytest-asyncio = "^0.23.3" -pytest-httpx = "^0.30.0" +pytest-httpx = "^0.35.0" [tool.poetry.group.jupyter.dependencies] ipywidgets = "^8.1.1" From 1e633cf6c2331ea95c9da626978a202f99ddcb60 Mon Sep 17 00:00:00 2001 From: Jan Range <30547301+JR-1991@users.noreply.github.com> Date: Tue, 18 Mar 2025 23:23:43 +0100 Subject: [PATCH 15/19] update `pytest-httpx` dep to resolve dep issues --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e89c195..3d1d994 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ ipywidgets = "^8.1.1" [tool.poetry.group.test.dependencies] pytest-cov = "^4.1.0" pytest-asyncio = "^0.23.3" -pytest-httpx = "^0.30.0" +pytest-httpx = "^0.35.0" [tool.poetry.group.linting.dependencies] codespell = "^2.2.6" From 5fee264e7f6fd941cc6f7b0917747298dcb77d66 Mon Sep 17 00:00:00 2001 From: Jan Range <30547301+JR-1991@users.noreply.github.com> Date: Tue, 18 Mar 2025 23:55:55 +0100 Subject: [PATCH 16/19] add more test cases - Loop-based upload of single tabular files - Files passed as an array of `File` objects - Parallelized upload --- .github/workflows/test.yml | 11 +-- tests/integration/test_native_upload.py | 113 +++++++++++++++++++++++- 2 files changed, 117 insertions(+), 7 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d4b0229..b0daa3e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,8 +13,8 @@ jobs: env: PORT: 8080 steps: - - name: "Checkout" - uses: "actions/checkout@v4" + - name: 'Checkout' + uses: 'actions/checkout@v4' - name: Run Dataverse Action id: dataverse uses: gdcc/dataverse-action@main @@ -29,8 +29,9 @@ jobs: poetry install --with test - name: Test with pytest env: - API_TOKEN: ${{ steps.dataverse.outputs.api_token }} - BASE_URL: ${{ steps.dataverse.outputs.base_url }} - DVUPLOADER_TESTING: "true" + API_TOKEN: ${{ steps.dataverse.outputs.api_token }} + BASE_URL: ${{ steps.dataverse.outputs.base_url }} + DVUPLOADER_TESTING: 'true' + TEST_ROWS: 100000 run: | python3 -m poetry run pytest diff --git a/tests/integration/test_native_upload.py b/tests/integration/test_native_upload.py index 93e5346..1c933b6 100644 --- a/tests/integration/test_native_upload.py +++ b/tests/integration/test_native_upload.py @@ -1,5 +1,6 @@ from io import BytesIO import json +import os import tempfile from dvuploader.dvuploader import DVUploader @@ -171,7 +172,7 @@ def test_native_upload_by_handler( f"Description does not match for file {json.dumps(file)}" ) - def test_native_upload_with_large_tabular_files( + def test_native_upload_with_large_tabular_files_loop( self, credentials, ): @@ -190,13 +191,21 @@ def test_native_upload_with_large_tabular_files( # The uploader should wait for the lock to be released and then upload # the file. # + rows = os.environ.get("TEST_ROWS", 10000) + + try: + rows = int(rows) + except ValueError: + raise ValueError(f"TEST_ROWS must be an integer, got {rows}") + + # We first try the sequential case by uploading 10 files in a loop. with tempfile.TemporaryDirectory() as directory: for i in range(10): # Arrange path = create_mock_tabular_file( directory, f"large_tabular_file_{i}.csv", - rows=10000, + rows=rows, cols=20, ) @@ -211,3 +220,103 @@ def test_native_upload_with_large_tabular_files( dataverse_url=BASE_URL, n_parallel_uploads=1, ) + + def test_native_upload_with_large_tabular_files( + self, + credentials, + ): + BASE_URL, API_TOKEN = credentials + + # Create Dataset + pid = create_dataset( + parent="Root", + server_url=BASE_URL, + api_token=API_TOKEN, + ) + + # We are uploading large tabular files in a loop to test the uploader's + # ability to wait for locks to be released. + # + # The uploader should wait for the lock to be released and then upload + # the file. + # + rows = os.environ.get("TEST_ROWS", 10000) + + try: + rows = int(rows) + except ValueError: + raise ValueError(f"TEST_ROWS must be an integer, got {rows}") + + # We first try the sequential case by uploading 10 files in a loop. + with tempfile.TemporaryDirectory() as directory: + files = [] + for i in range(10): + # Arrange + path = create_mock_tabular_file( + directory, + f"large_tabular_file_{i}.csv", + rows=rows, + cols=20, + ) + + # Add all files in the directory + files.append(File(filepath=path)) + + # Act + uploader = DVUploader(files=files) + uploader.upload( + persistent_id=pid, + api_token=API_TOKEN, + dataverse_url=BASE_URL, + n_parallel_uploads=1, + ) + + def test_native_upload_with_large_tabular_files_parallel( + self, + credentials, + ): + BASE_URL, API_TOKEN = credentials + + # Create Dataset + pid = create_dataset( + parent="Root", + server_url=BASE_URL, + api_token=API_TOKEN, + ) + + # We are uploading large tabular files in a loop to test the uploader's + # ability to wait for locks to be released. + # + # The uploader should wait for the lock to be released and then upload + # the file. + # + rows = os.environ.get("TEST_ROWS", 10000) + + try: + rows = int(rows) + except ValueError: + raise ValueError(f"TEST_ROWS must be an integer, got {rows}") + + # We first try the sequential case by uploading 10 files in a loop. + with tempfile.TemporaryDirectory() as directory: + files = [] + for i in range(10): + # Arrange + path = create_mock_tabular_file( + directory, + f"large_tabular_file_{i}.csv", + rows=rows, + cols=20, + ) + + # Add all files in the directory + files.append(File(filepath=path)) + + # Act + uploader = DVUploader(files=files) + uploader.upload( + persistent_id=pid, + api_token=API_TOKEN, + dataverse_url=BASE_URL, + n_parallel_uploads=10, + ) From 5643c012c933e38e69381019e75e1f4a587e81cc Mon Sep 17 00:00:00 2001 From: Jan Range <30547301+JR-1991@users.noreply.github.com> Date: Wed, 16 Apr 2025 10:35:21 +0200 Subject: [PATCH 17/19] remove print statement --- dvuploader/directupload.py | 1 - 1 file changed, 1 deletion(-) diff --git a/dvuploader/directupload.py b/dvuploader/directupload.py index dcfadd3..2ec3f1f 100644 --- a/dvuploader/directupload.py +++ b/dvuploader/directupload.py @@ -335,7 +335,6 @@ async def _upload_multipart( ) file.apply_checksum() - print(file.checksum) return True, storage_identifier From 09e13e6e89171a7a17c623087a614ab544f01733 Mon Sep 17 00:00:00 2001 From: Jan Range <30547301+JR-1991@users.noreply.github.com> Date: Wed, 16 Apr 2025 10:40:26 +0200 Subject: [PATCH 18/19] handle zip files and limit errors --- dvuploader/nativeupload.py | 38 ++++++++++++++++++++++++++++---------- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/dvuploader/nativeupload.py b/dvuploader/nativeupload.py index cc1c783..e661638 100644 --- a/dvuploader/nativeupload.py +++ b/dvuploader/nativeupload.py @@ -5,6 +5,7 @@ import json import os import tempfile +import rich import tenacity from typing import List, Tuple, Dict @@ -49,6 +50,10 @@ "tsv", ] +##### ERROR MESSAGES ##### + +ZIP_LIMIT_MESSAGE = "The number of files in the zip archive is over the limit" + async def native_upload( files: List[File], @@ -203,6 +208,7 @@ def _reset_progress( @tenacity.retry( wait=RETRY_STRAT, stop=tenacity.stop_after_attempt(MAX_RETRIES), + retry=tenacity.retry_if_exception_type((httpx.HTTPStatusError,)), ) async def _single_native_upload( session: httpx.AsyncClient, @@ -257,9 +263,20 @@ async def _single_native_upload( files=files, # type: ignore ) + if response.status_code == 400 and response.json()["message"].startswith( + ZIP_LIMIT_MESSAGE + ): + # Explicitly handle the zip limit error, because otherwise we will run into + # unnecessary retries. + raise ValueError( + f"Could not upload file '{file.file_name}' due to zip limit:\n{response.json()['message']}" + ) + + # Any other error is re-raised and the error will be handled by the retry logic. response.raise_for_status() if response.status_code == 200: + # If we did well, update the progress bar. progress.update(pbar, advance=file._size, complete=file._size) # Wait to avoid rate limiting @@ -326,15 +343,21 @@ async def _update_metadata( try: if _tab_extension(dv_path) in file_mapping: file_id = file_mapping[_tab_extension(dv_path)] + elif file.file_name and _is_zip(file.file_name): + # When the file is a zip it will be unpacked and thus + # the expected file name of the zip will not be in the + # dataset, since it has been unpacked. + continue else: file_id = file_mapping[dv_path] except KeyError: - raise ValueError( + rich.print( ( f"File {dv_path} not found in Dataverse repository.", "This may be due to the file not being uploaded to the repository:", ) ) + continue task = _update_single_metadata( session=session, @@ -389,6 +412,7 @@ async def _update_single_metadata( if response.status_code == 200: return else: + print(response.json()) await asyncio.sleep(1.0) raise ValueError(f"Failed to update metadata for file {file.file_name}.") @@ -449,14 +473,8 @@ def _tab_extension(path: str) -> str: return str(Path(path).with_suffix(".tab")) -def _is_tabular(file: File) -> bool: +def _is_zip(file_name: str) -> bool: """ - Checks if a file is a tabular file. + Checks if a file name ends with a zip extension. """ - is_tabular = False - - for extension in TABULAR_EXTENSIONS: - if file.file_name and file.file_name.endswith(extension): - is_tabular = True - break - return is_tabular + return file_name.endswith(".zip") From f5ccf5b253e9a553e82a6329bbdbdfb508f07279 Mon Sep 17 00:00:00 2001 From: Jan Range <30547301+JR-1991@users.noreply.github.com> Date: Wed, 16 Apr 2025 10:40:34 +0200 Subject: [PATCH 19/19] add zip test cases --- tests/fixtures/archive.zip | Bin 0 -> 978 bytes tests/fixtures/archive.zip.zip | Bin 0 -> 321 bytes tests/fixtures/many_files.zip | Bin 0 -> 246912 bytes tests/integration/test_native_upload.py | 123 ++++++++++++++++++++++++ 4 files changed, 123 insertions(+) create mode 100644 tests/fixtures/archive.zip create mode 100644 tests/fixtures/archive.zip.zip create mode 100644 tests/fixtures/many_files.zip diff --git a/tests/fixtures/archive.zip b/tests/fixtures/archive.zip new file mode 100644 index 0000000000000000000000000000000000000000..dfe0f43c959adea2bf9a99c4ed1e4f93538ceadb GIT binary patch literal 978 zcmWIWW@Zs#-~hs-JBp$hpdb=Rb22C}WF+R~c5q{__@;LXl4>11Dk5KtCmTmVjsxL_6;q1Y5k z(57X2oM3C_M6!P6C*B%k6l(~3h)iotP^_VuZ%k3FA=fvIOd`yPWPluRpkx38OBz8e zq7y}cH>$D7@dS!=7+BJn2{V?cBtX2m7hvWR73aj8Yt9G_2BIRFcys5$%q1#T1H4(; QKw-!Vgxi5SS(!jQ04t^RRR910 literal 0 HcmV?d00001 diff --git a/tests/fixtures/archive.zip.zip b/tests/fixtures/archive.zip.zip new file mode 100644 index 0000000000000000000000000000000000000000..e98d4706836c1c914854fbf7d096e6178071ad86 GIT binary patch literal 321 zcmWIWW@Zs#U|`^2nB!LyrE%?}>1iPE5;Fq>7m#)=O3uhE)2qrX;Qo-7CYbnu;m~61 zu0;|H6ofh~B}F8Rgo=wkeVx>O+R3>>@<#LhyFUDkNdmD^2hScj@oDPBM#dF$8xQ{O zdaimt-7TC)UvgFHY?+{^X&fKYp3f-Rr0>9y6~o}yy(2+Pa^VdTuM5jktL7wRKXchs z_f-9!RI-q*%Q927FCTj~Lu(G7P&w4Ify45i!$}6VX(mk`i-cKsZ9EZHv`}I;r}4BE zDh_*giYGi15`BJTcJ{dw4o)h<`R{CI>~uMr)nKkI?IEu%kjKuo$*8e{A;6oFNrV}< er-A+kgC&h13K0YW-mGjO5k?@i0Meg990mXoDQ}zr literal 0 HcmV?d00001 diff --git a/tests/fixtures/many_files.zip b/tests/fixtures/many_files.zip new file mode 100644 index 0000000000000000000000000000000000000000..87cc6ef4a89d48643acd88757da8fc2a9bf07d3c GIT binary patch literal 246912 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

W7wE;Y=0>K;Y9!OZ0m8hzm)%QqW>7Ya==K! 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�o9!yO^v8EN?VPBY9 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