Skip to content

Commit f3f8153

Browse files
authored
Merge branch 'main' into include-tab-ingest
2 parents f5ccf5b + 7ca980c commit f3f8153

8 files changed

Lines changed: 143 additions & 12 deletions

File tree

.github/workflows/test.yml

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,21 @@
11
name: Integration Tests
22

3-
on: [push]
3+
on: [push, pull_request]
44

55
jobs:
66
build:
77
runs-on: ubuntu-latest
8+
9+
services:
10+
squid:
11+
image: ubuntu/squid:latest
12+
ports:
13+
- 3128:3128
14+
815
strategy:
916
max-parallel: 4
1017
matrix:
11-
python-version: ['3.8', '3.9', '3.10', '3.11']
18+
python-version: ["3.8", "3.9", "3.10", "3.11"]
1219

1320
env:
1421
PORT: 8080

dvuploader/cli.py

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import yaml
22
import typer
33

4+
from pathlib import Path
45
from pydantic import BaseModel
56
from typing import List, Optional
67
from dvuploader import DVUploader, File
8+
from dvuploader.utils import add_directory
79

810

911
class CliInput(BaseModel):
@@ -27,6 +29,29 @@ class CliInput(BaseModel):
2729

2830
app = typer.Typer()
2931

32+
def _enumerate_filepaths(filepaths: List[str], recurse: bool) -> List[File]:
33+
"""
34+
Take a list of filepaths and transform it into a list of File objects, optionally recursing into each of them.
35+
36+
Args:
37+
filepaths (List[str]): a list of files or paths for upload
38+
recurse (bool): whether to recurse into each given filepath
39+
40+
Returns:
41+
List[File]: A list of File objects representing the files extracted from all filepaths.
42+
43+
Raises:
44+
FileNotFoundError: If a filepath does not exist.
45+
IsADirectoryError: If recurse is False and a filepath points to a directory instead of a file.
46+
"""
47+
if not recurse:
48+
return [File(filepath=filepath) for filepath in filepaths]
49+
50+
files = []
51+
for fp in filepaths:
52+
files.extend(add_directory(fp) if Path(fp).is_dir() else [File(filepath=fp)])
53+
return files
54+
3055

3156
def _parse_yaml_config(path: str) -> CliInput:
3257
"""
@@ -50,6 +75,7 @@ def _validate_inputs(
5075
pid: str,
5176
dataverse_url: str,
5277
api_token: str,
78+
recurse: bool,
5379
config_path: Optional[str],
5480
) -> None:
5581
"""
@@ -62,16 +88,23 @@ def _validate_inputs(
6288
pid (str): Persistent identifier of the dataset
6389
dataverse_url (str): URL of the Dataverse instance
6490
api_token (str): API token for authentication
91+
recurse (bool): Whether to recurse into filepaths
6592
config_path (Optional[str]): Path to configuration file
6693
6794
Raises:
6895
typer.BadParameter: If both config file and filepaths are specified
96+
typer.BadParameter: If both config file and recurse are specified
6997
typer.BadParameter: If neither config file nor required parameters are provided
7098
"""
71-
if config_path is not None and len(filepaths) > 0:
72-
raise typer.BadParameter(
73-
"Cannot specify both a JSON/YAML file and a list of filepaths."
74-
)
99+
if config_path is not None:
100+
if len(filepaths) > 0:
101+
raise typer.BadParameter(
102+
"Cannot specify both a JSON/YAML file and a list of filepaths."
103+
)
104+
if recurse:
105+
raise typer.BadParameter(
106+
"Cannot specify both a JSON/YAML file and recurse into filepaths."
107+
)
75108

76109
_has_meta_params = all(arg is not None for arg in [pid, dataverse_url, api_token])
77110
_has_config_file = config_path is not None
@@ -90,10 +123,14 @@ def _validate_inputs(
90123

91124
@app.command()
92125
def main(
93-
filepaths: List[str] = typer.Argument(
126+
filepaths: Optional[List[str]] = typer.Argument(
94127
default=None,
95128
help="A list of filepaths to upload.",
96129
),
130+
recurse: Optional[bool] = typer.Option(
131+
default=False,
132+
help="Enable recursion into filepaths.",
133+
),
97134
pid: str = typer.Option(
98135
default=None,
99136
help="The persistent identifier of the Dataverse dataset.",
@@ -123,6 +160,7 @@ def main(
123160
124161
If using command line arguments, you must specify:
125162
- One or more filepaths to upload
163+
- (Optional) whether to recurse into the filepaths
126164
- The dataset's persistent identifier
127165
- A valid API token
128166
- The Dataverse repository URL
@@ -141,11 +179,16 @@ def main(
141179
Upload files via config file:
142180
$ dvuploader --config-path upload_config.yaml
143181
"""
182+
183+
if filepaths is None:
184+
filepaths = []
185+
144186
_validate_inputs(
145187
filepaths=filepaths,
146188
pid=pid,
147189
dataverse_url=dataverse_url,
148190
api_token=api_token,
191+
recurse=recurse,
149192
config_path=config_path,
150193
)
151194

@@ -157,7 +200,7 @@ def main(
157200
api_token=api_token,
158201
dataverse_url=dataverse_url,
159202
persistent_id=pid,
160-
files=[File(filepath=filepath) for filepath in filepaths],
203+
files=_enumerate_filepaths(filepaths=filepaths, recurse=recurse),
161204
)
162205

163206
uploader = DVUploader(files=cli_input.files)

dvuploader/directupload.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ async def direct_upload(
3636
progress,
3737
pbars,
3838
n_parallel_uploads: int,
39+
proxy: Optional[str] = None,
3940
) -> None:
4041
"""
4142
Perform parallel direct upload of files to the specified Dataverse repository.
@@ -48,7 +49,7 @@ async def direct_upload(
4849
progress: Progress object to track upload progress.
4950
pbars: List of progress bars for each file.
5051
n_parallel_uploads (int): Number of concurrent uploads to perform.
51-
52+
proxy (str): The proxy to use for the upload.
5253
Returns:
5354
None
5455
"""
@@ -58,6 +59,7 @@ async def direct_upload(
5859
session_params = {
5960
"timeout": None,
6061
"limits": httpx.Limits(max_connections=n_parallel_uploads),
62+
"proxy": proxy,
6163
}
6264

6365
async with httpx.AsyncClient(**session_params) as session:

dvuploader/dvuploader.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ def upload(
5858
n_parallel_uploads: int = 1,
5959
force_native: bool = False,
6060
replace_existing: bool = True,
61+
proxy: Optional[str] = None,
6162
) -> None:
6263
"""
6364
Uploads the files to the specified Dataverse repository.
@@ -70,6 +71,7 @@ def upload(
7071
this restricts parallel chunks per upload. Use n_jobs to control parallel files.
7172
force_native (bool): Forces the use of the native upload method instead of direct upload.
7273
replace_existing (bool): Whether to replace files that already exist in the dataset.
74+
proxy (str): The proxy to use for the upload.
7375
7476
Returns:
7577
None

dvuploader/nativeupload.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import tempfile
88
import rich
99
import tenacity
10-
from typing import List, Tuple, Dict
10+
from typing import List, Optional, Tuple, Dict
1111

1212
from rich.progress import Progress, TaskID
1313

@@ -63,6 +63,7 @@ async def native_upload(
6363
n_parallel_uploads: int,
6464
pbars,
6565
progress,
66+
proxy: Optional[str] = None,
6667
):
6768
"""
6869
Executes native uploads for the given files in parallel.
@@ -75,7 +76,7 @@ async def native_upload(
7576
n_parallel_uploads (int): The number of parallel uploads to execute.
7677
pbars: List of progress bar IDs to track upload progress.
7778
progress: Progress object to manage progress bars.
78-
79+
proxy (str): The proxy to use for the upload.
7980
Returns:
8081
None
8182
"""
@@ -87,6 +88,7 @@ async def native_upload(
8788
"headers": {"X-Dataverse-key": api_token},
8889
"timeout": None,
8990
"limits": httpx.Limits(max_connections=n_parallel_uploads),
91+
"proxy": proxy,
9092
}
9193

9294
async with httpx.AsyncClient(**session_params) as session:

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ readme = "README.md"
99
python = "^3.9"
1010
pydantic = "^2.5.3"
1111
httpx = "^0.28"
12-
typer = "^0.9.0"
12+
typer = ">=0.9, <0.16"
1313
pyyaml = "^6.0.1"
1414
nest-asyncio = "^1.5.8"
1515
aiofiles = "^23.2.1"

tests/integration/test_native_upload.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,55 @@ def test_forced_native_upload(
108108
assert len(files) == 3
109109
assert sorted([file["label"] for file in files]) == sorted(expected_files)
110110

111+
def test_native_upload_with_proxy(
112+
self,
113+
credentials,
114+
):
115+
BASE_URL, API_TOKEN = credentials
116+
proxy = "http://127.0.0.1:3128"
117+
118+
with tempfile.TemporaryDirectory() as directory:
119+
# Arrange
120+
create_mock_file(directory, "small_file.txt", size=1)
121+
create_mock_file(directory, "mid_file.txt", size=50)
122+
create_mock_file(directory, "large_file.txt", size=200)
123+
124+
# Add all files in the directory
125+
files = add_directory(directory=directory)
126+
127+
# Create Dataset
128+
pid = create_dataset(
129+
parent="Root",
130+
server_url=BASE_URL,
131+
api_token=API_TOKEN,
132+
)
133+
134+
# Act
135+
uploader = DVUploader(files=files)
136+
uploader.upload(
137+
persistent_id=pid,
138+
api_token=API_TOKEN,
139+
dataverse_url=BASE_URL,
140+
n_parallel_uploads=1,
141+
proxy=proxy,
142+
)
143+
144+
# Assert
145+
files = retrieve_dataset_files(
146+
dataverse_url=BASE_URL,
147+
persistent_id=pid,
148+
api_token=API_TOKEN,
149+
)
150+
151+
expected_files = [
152+
"small_file.txt",
153+
"mid_file.txt",
154+
"large_file.txt",
155+
]
156+
157+
assert len(files) == 3
158+
assert sorted([file["label"] for file in files]) == sorted(expected_files)
159+
111160
def test_native_upload_by_handler(
112161
self,
113162
credentials,

tests/unit/test_cli.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,32 @@ def test_kwarg_arg_input(self, credentials):
5757
)
5858
assert result.exit_code == 0
5959

60+
def test_recurse(self, credentials):
61+
# Arrange
62+
BASE_URL, API_TOKEN = credentials
63+
pid = create_dataset(
64+
parent="Root",
65+
server_url=BASE_URL,
66+
api_token=API_TOKEN,
67+
)
68+
69+
# Act
70+
result = runner.invoke(
71+
app,
72+
[
73+
"./tests/fixtures/create_dataset.json",
74+
"./tests/fixtures/add_dir_files",
75+
"--recurse",
76+
"--pid",
77+
pid,
78+
"--api-token",
79+
API_TOKEN,
80+
"--dataverse-url",
81+
BASE_URL,
82+
],
83+
)
84+
assert result.exit_code == 0
85+
6086
def test_yaml_input(self, credentials):
6187
# Arrange
6288
BASE_URL, API_TOKEN = credentials

0 commit comments

Comments
 (0)