elaunira-r2index 0.2.0__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {elaunira_r2index-0.2.0 → elaunira_r2index-0.3.0}/PKG-INFO +28 -32
- {elaunira_r2index-0.2.0 → elaunira_r2index-0.3.0}/README.md +27 -31
- {elaunira_r2index-0.2.0 → elaunira_r2index-0.3.0}/pyproject.toml +1 -1
- {elaunira_r2index-0.2.0 → elaunira_r2index-0.3.0}/src/elaunira/r2index/async_client.py +40 -25
- {elaunira_r2index-0.2.0 → elaunira_r2index-0.3.0}/src/elaunira/r2index/async_storage.py +12 -6
- {elaunira_r2index-0.2.0 → elaunira_r2index-0.3.0}/src/elaunira/r2index/client.py +40 -25
- {elaunira_r2index-0.2.0 → elaunira_r2index-0.3.0}/src/elaunira/r2index/storage.py +13 -8
- {elaunira_r2index-0.2.0 → elaunira_r2index-0.3.0}/tests/test_async_client.py +8 -8
- {elaunira_r2index-0.2.0 → elaunira_r2index-0.3.0}/tests/test_client.py +16 -16
- {elaunira_r2index-0.2.0 → elaunira_r2index-0.3.0}/tests/test_download.py +25 -29
- {elaunira_r2index-0.2.0 → elaunira_r2index-0.3.0}/.gitignore +0 -0
- {elaunira_r2index-0.2.0 → elaunira_r2index-0.3.0}/scripts/publish.sh +0 -0
- {elaunira_r2index-0.2.0 → elaunira_r2index-0.3.0}/src/elaunira/__init__.py +0 -0
- {elaunira_r2index-0.2.0 → elaunira_r2index-0.3.0}/src/elaunira/r2index/__init__.py +0 -0
- {elaunira_r2index-0.2.0 → elaunira_r2index-0.3.0}/src/elaunira/r2index/checksums.py +0 -0
- {elaunira_r2index-0.2.0 → elaunira_r2index-0.3.0}/src/elaunira/r2index/exceptions.py +0 -0
- {elaunira_r2index-0.2.0 → elaunira_r2index-0.3.0}/src/elaunira/r2index/models.py +0 -0
- {elaunira_r2index-0.2.0 → elaunira_r2index-0.3.0}/src/elaunira/r2index/py.typed +0 -0
- {elaunira_r2index-0.2.0 → elaunira_r2index-0.3.0}/tests/__init__.py +0 -0
- {elaunira_r2index-0.2.0 → elaunira_r2index-0.3.0}/tests/test_checksums.py +0 -0
- {elaunira_r2index-0.2.0 → elaunira_r2index-0.3.0}/tests/test_models.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: elaunira-r2index
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Python library for uploading files to R2 and registering them with the r2index API
|
|
5
5
|
Project-URL: Homepage, https://github.com/elaunira/elaunira-r2-index
|
|
6
6
|
Project-URL: Repository, https://github.com/elaunira/elaunira-r2-index
|
|
@@ -45,23 +45,20 @@ pip install elaunira-r2index
|
|
|
45
45
|
### Sync Client
|
|
46
46
|
|
|
47
47
|
```python
|
|
48
|
-
from elaunira.r2index import R2IndexClient
|
|
48
|
+
from elaunira.r2index import R2IndexClient
|
|
49
49
|
|
|
50
50
|
client = R2IndexClient(
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
endpoint_url="https://your-account-id.r2.cloudflarestorage.com",
|
|
57
|
-
bucket="your-bucket-name",
|
|
58
|
-
),
|
|
51
|
+
index_api_url="https://r2index.example.com",
|
|
52
|
+
index_api_token="your-bearer-token",
|
|
53
|
+
r2_access_key_id="your-r2-access-key-id",
|
|
54
|
+
r2_secret_access_key="your-r2-secret-access-key",
|
|
55
|
+
r2_endpoint_url="https://your-account-id.r2.cloudflarestorage.com",
|
|
59
56
|
)
|
|
60
57
|
|
|
61
58
|
# Upload and register a file
|
|
62
|
-
record = client.
|
|
63
|
-
local_path="./myfile.zip",
|
|
59
|
+
record = client.upload(
|
|
64
60
|
bucket="my-bucket",
|
|
61
|
+
local_path="./myfile.zip",
|
|
65
62
|
category="software",
|
|
66
63
|
entity="myapp",
|
|
67
64
|
remote_path="/releases/myapp",
|
|
@@ -71,8 +68,8 @@ record = client.upload_and_register(
|
|
|
71
68
|
)
|
|
72
69
|
|
|
73
70
|
# Download a file and record the download
|
|
74
|
-
# IP address is auto-detected, user agent defaults to "elaunira-r2index
|
|
75
|
-
path, record = client.
|
|
71
|
+
# IP address is auto-detected, user agent defaults to "elaunira-r2index/<version>"
|
|
72
|
+
path, record = client.download(
|
|
76
73
|
bucket="my-bucket",
|
|
77
74
|
object_id="/releases/myapp/v1/myapp.zip",
|
|
78
75
|
destination="./downloads/myfile.zip",
|
|
@@ -82,22 +79,19 @@ path, record = client.download_and_record(
|
|
|
82
79
|
### Async Client
|
|
83
80
|
|
|
84
81
|
```python
|
|
85
|
-
from elaunira.r2index import AsyncR2IndexClient
|
|
82
|
+
from elaunira.r2index import AsyncR2IndexClient
|
|
86
83
|
|
|
87
84
|
async with AsyncR2IndexClient(
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
endpoint_url="https://your-account-id.r2.cloudflarestorage.com",
|
|
94
|
-
bucket="your-bucket-name",
|
|
95
|
-
),
|
|
85
|
+
index_api_url="https://r2index.example.com",
|
|
86
|
+
index_api_token="your-bearer-token",
|
|
87
|
+
r2_access_key_id="your-r2-access-key-id",
|
|
88
|
+
r2_secret_access_key="your-r2-secret-access-key",
|
|
89
|
+
r2_endpoint_url="https://your-account-id.r2.cloudflarestorage.com",
|
|
96
90
|
) as client:
|
|
97
91
|
# Upload
|
|
98
|
-
record = await client.
|
|
99
|
-
local_path="./myfile.zip",
|
|
92
|
+
record = await client.upload(
|
|
100
93
|
bucket="my-bucket",
|
|
94
|
+
local_path="./myfile.zip",
|
|
101
95
|
category="software",
|
|
102
96
|
entity="myapp",
|
|
103
97
|
remote_path="/releases/myapp",
|
|
@@ -107,7 +101,7 @@ async with AsyncR2IndexClient(
|
|
|
107
101
|
)
|
|
108
102
|
|
|
109
103
|
# Download
|
|
110
|
-
path, record = await client.
|
|
104
|
+
path, record = await client.download(
|
|
111
105
|
bucket="my-bucket",
|
|
112
106
|
object_id="/releases/myapp/v1/myapp.zip",
|
|
113
107
|
destination="./downloads/myfile.zip",
|
|
@@ -119,12 +113,14 @@ async with AsyncR2IndexClient(
|
|
|
119
113
|
Control multipart transfer settings with `R2TransferConfig`:
|
|
120
114
|
|
|
121
115
|
```python
|
|
122
|
-
from elaunira.r2index import R2IndexClient,
|
|
116
|
+
from elaunira.r2index import R2IndexClient, R2TransferConfig
|
|
123
117
|
|
|
124
118
|
client = R2IndexClient(
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
119
|
+
index_api_url="https://r2index.example.com",
|
|
120
|
+
index_api_token="your-bearer-token",
|
|
121
|
+
r2_access_key_id="your-r2-access-key-id",
|
|
122
|
+
r2_secret_access_key="your-r2-secret-access-key",
|
|
123
|
+
r2_endpoint_url="https://your-account-id.r2.cloudflarestorage.com",
|
|
128
124
|
)
|
|
129
125
|
|
|
130
126
|
# Custom transfer settings
|
|
@@ -135,7 +131,7 @@ transfer_config = R2TransferConfig(
|
|
|
135
131
|
use_threads=True, # Enable threading (default)
|
|
136
132
|
)
|
|
137
133
|
|
|
138
|
-
path, record = client.
|
|
134
|
+
path, record = client.download(
|
|
139
135
|
bucket="my-bucket",
|
|
140
136
|
object_id="/data/files/v2/largefile.zip",
|
|
141
137
|
destination="./downloads/largefile.zip",
|
|
@@ -151,7 +147,7 @@ Default `max_concurrency` is 2x the number of CPU cores (minimum 4).
|
|
|
151
147
|
def on_progress(bytes_transferred: int) -> None:
|
|
152
148
|
print(f"Downloaded: {bytes_transferred / 1024 / 1024:.1f} MB")
|
|
153
149
|
|
|
154
|
-
path, record = client.
|
|
150
|
+
path, record = client.download(
|
|
155
151
|
bucket="my-bucket",
|
|
156
152
|
object_id="/releases/myapp/v1/myapp.zip",
|
|
157
153
|
destination="./downloads/myfile.zip",
|
|
@@ -13,23 +13,20 @@ pip install elaunira-r2index
|
|
|
13
13
|
### Sync Client
|
|
14
14
|
|
|
15
15
|
```python
|
|
16
|
-
from elaunira.r2index import R2IndexClient
|
|
16
|
+
from elaunira.r2index import R2IndexClient
|
|
17
17
|
|
|
18
18
|
client = R2IndexClient(
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
endpoint_url="https://your-account-id.r2.cloudflarestorage.com",
|
|
25
|
-
bucket="your-bucket-name",
|
|
26
|
-
),
|
|
19
|
+
index_api_url="https://r2index.example.com",
|
|
20
|
+
index_api_token="your-bearer-token",
|
|
21
|
+
r2_access_key_id="your-r2-access-key-id",
|
|
22
|
+
r2_secret_access_key="your-r2-secret-access-key",
|
|
23
|
+
r2_endpoint_url="https://your-account-id.r2.cloudflarestorage.com",
|
|
27
24
|
)
|
|
28
25
|
|
|
29
26
|
# Upload and register a file
|
|
30
|
-
record = client.
|
|
31
|
-
local_path="./myfile.zip",
|
|
27
|
+
record = client.upload(
|
|
32
28
|
bucket="my-bucket",
|
|
29
|
+
local_path="./myfile.zip",
|
|
33
30
|
category="software",
|
|
34
31
|
entity="myapp",
|
|
35
32
|
remote_path="/releases/myapp",
|
|
@@ -39,8 +36,8 @@ record = client.upload_and_register(
|
|
|
39
36
|
)
|
|
40
37
|
|
|
41
38
|
# Download a file and record the download
|
|
42
|
-
# IP address is auto-detected, user agent defaults to "elaunira-r2index
|
|
43
|
-
path, record = client.
|
|
39
|
+
# IP address is auto-detected, user agent defaults to "elaunira-r2index/<version>"
|
|
40
|
+
path, record = client.download(
|
|
44
41
|
bucket="my-bucket",
|
|
45
42
|
object_id="/releases/myapp/v1/myapp.zip",
|
|
46
43
|
destination="./downloads/myfile.zip",
|
|
@@ -50,22 +47,19 @@ path, record = client.download_and_record(
|
|
|
50
47
|
### Async Client
|
|
51
48
|
|
|
52
49
|
```python
|
|
53
|
-
from elaunira.r2index import AsyncR2IndexClient
|
|
50
|
+
from elaunira.r2index import AsyncR2IndexClient
|
|
54
51
|
|
|
55
52
|
async with AsyncR2IndexClient(
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
endpoint_url="https://your-account-id.r2.cloudflarestorage.com",
|
|
62
|
-
bucket="your-bucket-name",
|
|
63
|
-
),
|
|
53
|
+
index_api_url="https://r2index.example.com",
|
|
54
|
+
index_api_token="your-bearer-token",
|
|
55
|
+
r2_access_key_id="your-r2-access-key-id",
|
|
56
|
+
r2_secret_access_key="your-r2-secret-access-key",
|
|
57
|
+
r2_endpoint_url="https://your-account-id.r2.cloudflarestorage.com",
|
|
64
58
|
) as client:
|
|
65
59
|
# Upload
|
|
66
|
-
record = await client.
|
|
67
|
-
local_path="./myfile.zip",
|
|
60
|
+
record = await client.upload(
|
|
68
61
|
bucket="my-bucket",
|
|
62
|
+
local_path="./myfile.zip",
|
|
69
63
|
category="software",
|
|
70
64
|
entity="myapp",
|
|
71
65
|
remote_path="/releases/myapp",
|
|
@@ -75,7 +69,7 @@ async with AsyncR2IndexClient(
|
|
|
75
69
|
)
|
|
76
70
|
|
|
77
71
|
# Download
|
|
78
|
-
path, record = await client.
|
|
72
|
+
path, record = await client.download(
|
|
79
73
|
bucket="my-bucket",
|
|
80
74
|
object_id="/releases/myapp/v1/myapp.zip",
|
|
81
75
|
destination="./downloads/myfile.zip",
|
|
@@ -87,12 +81,14 @@ async with AsyncR2IndexClient(
|
|
|
87
81
|
Control multipart transfer settings with `R2TransferConfig`:
|
|
88
82
|
|
|
89
83
|
```python
|
|
90
|
-
from elaunira.r2index import R2IndexClient,
|
|
84
|
+
from elaunira.r2index import R2IndexClient, R2TransferConfig
|
|
91
85
|
|
|
92
86
|
client = R2IndexClient(
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
87
|
+
index_api_url="https://r2index.example.com",
|
|
88
|
+
index_api_token="your-bearer-token",
|
|
89
|
+
r2_access_key_id="your-r2-access-key-id",
|
|
90
|
+
r2_secret_access_key="your-r2-secret-access-key",
|
|
91
|
+
r2_endpoint_url="https://your-account-id.r2.cloudflarestorage.com",
|
|
96
92
|
)
|
|
97
93
|
|
|
98
94
|
# Custom transfer settings
|
|
@@ -103,7 +99,7 @@ transfer_config = R2TransferConfig(
|
|
|
103
99
|
use_threads=True, # Enable threading (default)
|
|
104
100
|
)
|
|
105
101
|
|
|
106
|
-
path, record = client.
|
|
102
|
+
path, record = client.download(
|
|
107
103
|
bucket="my-bucket",
|
|
108
104
|
object_id="/data/files/v2/largefile.zip",
|
|
109
105
|
destination="./downloads/largefile.zip",
|
|
@@ -119,7 +115,7 @@ Default `max_concurrency` is 2x the number of CPU cores (minimum 4).
|
|
|
119
115
|
def on_progress(bytes_transferred: int) -> None:
|
|
120
116
|
print(f"Downloaded: {bytes_transferred / 1024 / 1024:.1f} MB")
|
|
121
117
|
|
|
122
|
-
path, record = client.
|
|
118
|
+
path, record = client.download(
|
|
123
119
|
bucket="my-bucket",
|
|
124
120
|
object_id="/releases/myapp/v1/myapp.zip",
|
|
125
121
|
destination="./downloads/myfile.zip",
|
|
@@ -81,29 +81,42 @@ class AsyncR2IndexClient:
|
|
|
81
81
|
|
|
82
82
|
def __init__(
|
|
83
83
|
self,
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
84
|
+
index_api_url: str,
|
|
85
|
+
index_api_token: str,
|
|
86
|
+
r2_access_key_id: str | None = None,
|
|
87
|
+
r2_secret_access_key: str | None = None,
|
|
88
|
+
r2_endpoint_url: str | None = None,
|
|
87
89
|
timeout: float = 30.0,
|
|
88
90
|
) -> None:
|
|
89
91
|
"""
|
|
90
92
|
Initialize the async R2Index client.
|
|
91
93
|
|
|
92
94
|
Args:
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
95
|
+
index_api_url: Base URL of the r2index API.
|
|
96
|
+
index_api_token: Bearer token for authentication.
|
|
97
|
+
r2_access_key_id: R2 access key ID for storage operations.
|
|
98
|
+
r2_secret_access_key: R2 secret access key for storage operations.
|
|
99
|
+
r2_endpoint_url: R2 endpoint URL for storage operations.
|
|
96
100
|
timeout: Request timeout in seconds.
|
|
97
101
|
"""
|
|
98
|
-
self.api_url =
|
|
99
|
-
self._token =
|
|
102
|
+
self.api_url = index_api_url.rstrip("/")
|
|
103
|
+
self._token = index_api_token
|
|
100
104
|
self._timeout = timeout
|
|
101
|
-
self._r2_config = r2_config
|
|
102
105
|
self._storage: AsyncR2Storage | None = None
|
|
103
106
|
|
|
107
|
+
# Build R2 config if credentials provided
|
|
108
|
+
if r2_access_key_id and r2_secret_access_key and r2_endpoint_url:
|
|
109
|
+
self._r2_config: R2Config | None = R2Config(
|
|
110
|
+
access_key_id=r2_access_key_id,
|
|
111
|
+
secret_access_key=r2_secret_access_key,
|
|
112
|
+
endpoint_url=r2_endpoint_url,
|
|
113
|
+
)
|
|
114
|
+
else:
|
|
115
|
+
self._r2_config = None
|
|
116
|
+
|
|
104
117
|
self._client = httpx.AsyncClient(
|
|
105
118
|
base_url=self.api_url,
|
|
106
|
-
headers={"Authorization": f"Bearer {
|
|
119
|
+
headers={"Authorization": f"Bearer {index_api_token}"},
|
|
107
120
|
timeout=timeout,
|
|
108
121
|
)
|
|
109
122
|
|
|
@@ -150,7 +163,7 @@ class AsyncR2IndexClient:
|
|
|
150
163
|
|
|
151
164
|
# File Operations
|
|
152
165
|
|
|
153
|
-
async def
|
|
166
|
+
async def list(
|
|
154
167
|
self,
|
|
155
168
|
bucket: str | None = None,
|
|
156
169
|
category: str | None = None,
|
|
@@ -191,7 +204,7 @@ class AsyncR2IndexClient:
|
|
|
191
204
|
data = self._handle_response(response)
|
|
192
205
|
return FileListResponse.model_validate(data)
|
|
193
206
|
|
|
194
|
-
async def
|
|
207
|
+
async def create(self, data: FileCreateRequest) -> FileRecord:
|
|
195
208
|
"""
|
|
196
209
|
Create or upsert a file record.
|
|
197
210
|
|
|
@@ -205,7 +218,7 @@ class AsyncR2IndexClient:
|
|
|
205
218
|
result = self._handle_response(response)
|
|
206
219
|
return FileRecord.model_validate(result)
|
|
207
220
|
|
|
208
|
-
async def
|
|
221
|
+
async def get(self, file_id: str) -> FileRecord:
|
|
209
222
|
"""
|
|
210
223
|
Get a file by ID.
|
|
211
224
|
|
|
@@ -222,7 +235,7 @@ class AsyncR2IndexClient:
|
|
|
222
235
|
data = self._handle_response(response)
|
|
223
236
|
return FileRecord.model_validate(data)
|
|
224
237
|
|
|
225
|
-
async def
|
|
238
|
+
async def update(self, file_id: str, data: FileUpdateRequest) -> FileRecord:
|
|
226
239
|
"""
|
|
227
240
|
Update a file record.
|
|
228
241
|
|
|
@@ -240,7 +253,7 @@ class AsyncR2IndexClient:
|
|
|
240
253
|
result = self._handle_response(response)
|
|
241
254
|
return FileRecord.model_validate(result)
|
|
242
255
|
|
|
243
|
-
async def
|
|
256
|
+
async def delete(self, file_id: str) -> None:
|
|
244
257
|
"""
|
|
245
258
|
Delete a file by ID.
|
|
246
259
|
|
|
@@ -253,7 +266,7 @@ class AsyncR2IndexClient:
|
|
|
253
266
|
response = await self._client.delete(f"/files/{file_id}")
|
|
254
267
|
self._handle_response(response)
|
|
255
268
|
|
|
256
|
-
async def
|
|
269
|
+
async def delete_by_tuple(self, remote_tuple: RemoteTuple) -> None:
|
|
257
270
|
"""
|
|
258
271
|
Delete a file by remote tuple.
|
|
259
272
|
|
|
@@ -272,7 +285,7 @@ class AsyncR2IndexClient:
|
|
|
272
285
|
response = await self._client.delete("/files", params=params)
|
|
273
286
|
self._handle_response(response)
|
|
274
287
|
|
|
275
|
-
async def
|
|
288
|
+
async def get_by_tuple(self, remote_tuple: RemoteTuple) -> FileRecord:
|
|
276
289
|
"""
|
|
277
290
|
Get a file by remote tuple.
|
|
278
291
|
|
|
@@ -295,7 +308,7 @@ class AsyncR2IndexClient:
|
|
|
295
308
|
data = self._handle_response(response)
|
|
296
309
|
return FileRecord.model_validate(data)
|
|
297
310
|
|
|
298
|
-
async def
|
|
311
|
+
async def index(
|
|
299
312
|
self,
|
|
300
313
|
bucket: str | None = None,
|
|
301
314
|
category: str | None = None,
|
|
@@ -497,10 +510,10 @@ class AsyncR2IndexClient:
|
|
|
497
510
|
|
|
498
511
|
# High-Level Pipeline
|
|
499
512
|
|
|
500
|
-
async def
|
|
513
|
+
async def upload(
|
|
501
514
|
self,
|
|
502
|
-
local_path: str | Path,
|
|
503
515
|
bucket: str,
|
|
516
|
+
local_path: str | Path,
|
|
504
517
|
category: str,
|
|
505
518
|
entity: str,
|
|
506
519
|
remote_path: str,
|
|
@@ -521,8 +534,8 @@ class AsyncR2IndexClient:
|
|
|
521
534
|
3. Register with r2index API
|
|
522
535
|
|
|
523
536
|
Args:
|
|
524
|
-
local_path: Local path to the file to upload.
|
|
525
537
|
bucket: The S3/R2 bucket name.
|
|
538
|
+
local_path: Local path to the file to upload.
|
|
526
539
|
category: File category.
|
|
527
540
|
entity: File entity.
|
|
528
541
|
remote_path: Remote path in R2 (e.g., "/data/files").
|
|
@@ -553,6 +566,7 @@ class AsyncR2IndexClient:
|
|
|
553
566
|
# Step 3: Upload to R2
|
|
554
567
|
await uploader.upload_file(
|
|
555
568
|
local_path,
|
|
569
|
+
bucket,
|
|
556
570
|
object_key,
|
|
557
571
|
content_type=content_type,
|
|
558
572
|
progress_callback=progress_callback,
|
|
@@ -576,7 +590,7 @@ class AsyncR2IndexClient:
|
|
|
576
590
|
sha512=checksums.sha512,
|
|
577
591
|
)
|
|
578
592
|
|
|
579
|
-
return await self.
|
|
593
|
+
return await self.create(create_request)
|
|
580
594
|
|
|
581
595
|
async def _get_public_ip(self) -> str:
|
|
582
596
|
"""Fetch public IP address from checkip.amazonaws.com."""
|
|
@@ -584,7 +598,7 @@ class AsyncR2IndexClient:
|
|
|
584
598
|
response = await client.get(CHECKIP_URL, timeout=10.0)
|
|
585
599
|
return response.text.strip()
|
|
586
600
|
|
|
587
|
-
async def
|
|
601
|
+
async def download(
|
|
588
602
|
self,
|
|
589
603
|
bucket: str,
|
|
590
604
|
object_id: str,
|
|
@@ -613,7 +627,7 @@ class AsyncR2IndexClient:
|
|
|
613
627
|
destination: Local path where the file will be saved.
|
|
614
628
|
ip_address: IP address of the downloader. If not provided, fetched
|
|
615
629
|
from checkip.amazonaws.com.
|
|
616
|
-
user_agent: User agent string. Defaults to "elaunira-r2index
|
|
630
|
+
user_agent: User agent string. Defaults to "elaunira-r2index/<version>".
|
|
617
631
|
progress_callback: Optional callback for download progress.
|
|
618
632
|
transfer_config: Optional transfer configuration for multipart/threading.
|
|
619
633
|
|
|
@@ -638,11 +652,12 @@ class AsyncR2IndexClient:
|
|
|
638
652
|
remote_tuple = _parse_object_id(object_id, bucket)
|
|
639
653
|
|
|
640
654
|
# Step 2: Get file record by tuple
|
|
641
|
-
file_record = await self.
|
|
655
|
+
file_record = await self.get_by_tuple(remote_tuple)
|
|
642
656
|
|
|
643
657
|
# Step 3: Build R2 object key and download
|
|
644
658
|
object_key = object_id.strip("/")
|
|
645
659
|
downloaded_path = await storage.download_file(
|
|
660
|
+
bucket,
|
|
646
661
|
object_key,
|
|
647
662
|
destination,
|
|
648
663
|
progress_callback=progress_callback,
|
|
@@ -26,6 +26,7 @@ class AsyncR2Storage:
|
|
|
26
26
|
async def upload_file(
|
|
27
27
|
self,
|
|
28
28
|
file_path: str | Path,
|
|
29
|
+
bucket: str,
|
|
29
30
|
object_key: str,
|
|
30
31
|
content_type: str | None = None,
|
|
31
32
|
progress_callback: Callable[[int], None] | None = None,
|
|
@@ -38,6 +39,7 @@ class AsyncR2Storage:
|
|
|
38
39
|
|
|
39
40
|
Args:
|
|
40
41
|
file_path: Path to the file to upload.
|
|
42
|
+
bucket: The R2 bucket name.
|
|
41
43
|
object_key: The key (path) to store the object under in R2.
|
|
42
44
|
content_type: Optional content type for the object.
|
|
43
45
|
progress_callback: Optional callback called with bytes uploaded so far.
|
|
@@ -78,7 +80,7 @@ class AsyncR2Storage:
|
|
|
78
80
|
|
|
79
81
|
await client.upload_file(
|
|
80
82
|
str(file_path),
|
|
81
|
-
|
|
83
|
+
bucket,
|
|
82
84
|
object_key,
|
|
83
85
|
ExtraArgs=extra_args if extra_args else None,
|
|
84
86
|
Callback=callback,
|
|
@@ -88,11 +90,12 @@ class AsyncR2Storage:
|
|
|
88
90
|
|
|
89
91
|
return object_key
|
|
90
92
|
|
|
91
|
-
async def delete_object(self, object_key: str) -> None:
|
|
93
|
+
async def delete_object(self, bucket: str, object_key: str) -> None:
|
|
92
94
|
"""
|
|
93
95
|
Delete an object from R2 asynchronously.
|
|
94
96
|
|
|
95
97
|
Args:
|
|
98
|
+
bucket: The R2 bucket name.
|
|
96
99
|
object_key: The key of the object to delete.
|
|
97
100
|
|
|
98
101
|
Raises:
|
|
@@ -106,15 +109,16 @@ class AsyncR2Storage:
|
|
|
106
109
|
endpoint_url=self.config.endpoint_url,
|
|
107
110
|
region_name=self.config.region,
|
|
108
111
|
) as client:
|
|
109
|
-
await client.delete_object(Bucket=
|
|
112
|
+
await client.delete_object(Bucket=bucket, Key=object_key)
|
|
110
113
|
except Exception as e:
|
|
111
114
|
raise UploadError(f"Failed to delete object from R2: {e}") from e
|
|
112
115
|
|
|
113
|
-
async def object_exists(self, object_key: str) -> bool:
|
|
116
|
+
async def object_exists(self, bucket: str, object_key: str) -> bool:
|
|
114
117
|
"""
|
|
115
118
|
Check if an object exists in R2 asynchronously.
|
|
116
119
|
|
|
117
120
|
Args:
|
|
121
|
+
bucket: The R2 bucket name.
|
|
118
122
|
object_key: The key of the object to check.
|
|
119
123
|
|
|
120
124
|
Returns:
|
|
@@ -128,7 +132,7 @@ class AsyncR2Storage:
|
|
|
128
132
|
endpoint_url=self.config.endpoint_url,
|
|
129
133
|
region_name=self.config.region,
|
|
130
134
|
) as client:
|
|
131
|
-
await client.head_object(Bucket=
|
|
135
|
+
await client.head_object(Bucket=bucket, Key=object_key)
|
|
132
136
|
return True
|
|
133
137
|
except client.exceptions.ClientError as e:
|
|
134
138
|
if e.response["Error"]["Code"] == "404":
|
|
@@ -137,6 +141,7 @@ class AsyncR2Storage:
|
|
|
137
141
|
|
|
138
142
|
async def download_file(
|
|
139
143
|
self,
|
|
144
|
+
bucket: str,
|
|
140
145
|
object_key: str,
|
|
141
146
|
file_path: str | Path,
|
|
142
147
|
progress_callback: Callable[[int], None] | None = None,
|
|
@@ -146,6 +151,7 @@ class AsyncR2Storage:
|
|
|
146
151
|
Download a file from R2 asynchronously.
|
|
147
152
|
|
|
148
153
|
Args:
|
|
154
|
+
bucket: The R2 bucket name.
|
|
149
155
|
object_key: The key (path) of the object in R2.
|
|
150
156
|
file_path: Local path where the file will be saved.
|
|
151
157
|
progress_callback: Optional callback called with bytes downloaded so far.
|
|
@@ -181,7 +187,7 @@ class AsyncR2Storage:
|
|
|
181
187
|
callback = _AsyncProgressCallback(progress_callback)
|
|
182
188
|
|
|
183
189
|
await client.download_file(
|
|
184
|
-
|
|
190
|
+
bucket,
|
|
185
191
|
object_key,
|
|
186
192
|
str(file_path),
|
|
187
193
|
Callback=callback,
|
|
@@ -80,29 +80,42 @@ class R2IndexClient:
|
|
|
80
80
|
|
|
81
81
|
def __init__(
|
|
82
82
|
self,
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
83
|
+
index_api_url: str,
|
|
84
|
+
index_api_token: str,
|
|
85
|
+
r2_access_key_id: str | None = None,
|
|
86
|
+
r2_secret_access_key: str | None = None,
|
|
87
|
+
r2_endpoint_url: str | None = None,
|
|
86
88
|
timeout: float = 30.0,
|
|
87
89
|
) -> None:
|
|
88
90
|
"""
|
|
89
91
|
Initialize the R2Index client.
|
|
90
92
|
|
|
91
93
|
Args:
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
94
|
+
index_api_url: Base URL of the r2index API.
|
|
95
|
+
index_api_token: Bearer token for authentication.
|
|
96
|
+
r2_access_key_id: R2 access key ID for storage operations.
|
|
97
|
+
r2_secret_access_key: R2 secret access key for storage operations.
|
|
98
|
+
r2_endpoint_url: R2 endpoint URL for storage operations.
|
|
95
99
|
timeout: Request timeout in seconds.
|
|
96
100
|
"""
|
|
97
|
-
self.api_url =
|
|
98
|
-
self._token =
|
|
101
|
+
self.api_url = index_api_url.rstrip("/")
|
|
102
|
+
self._token = index_api_token
|
|
99
103
|
self._timeout = timeout
|
|
100
|
-
self._r2_config = r2_config
|
|
101
104
|
self._storage: R2Storage | None = None
|
|
102
105
|
|
|
106
|
+
# Build R2 config if credentials provided
|
|
107
|
+
if r2_access_key_id and r2_secret_access_key and r2_endpoint_url:
|
|
108
|
+
self._r2_config: R2Config | None = R2Config(
|
|
109
|
+
access_key_id=r2_access_key_id,
|
|
110
|
+
secret_access_key=r2_secret_access_key,
|
|
111
|
+
endpoint_url=r2_endpoint_url,
|
|
112
|
+
)
|
|
113
|
+
else:
|
|
114
|
+
self._r2_config = None
|
|
115
|
+
|
|
103
116
|
self._client = httpx.Client(
|
|
104
117
|
base_url=self.api_url,
|
|
105
|
-
headers={"Authorization": f"Bearer {
|
|
118
|
+
headers={"Authorization": f"Bearer {index_api_token}"},
|
|
106
119
|
timeout=timeout,
|
|
107
120
|
)
|
|
108
121
|
|
|
@@ -149,7 +162,7 @@ class R2IndexClient:
|
|
|
149
162
|
|
|
150
163
|
# File Operations
|
|
151
164
|
|
|
152
|
-
def
|
|
165
|
+
def list(
|
|
153
166
|
self,
|
|
154
167
|
bucket: str | None = None,
|
|
155
168
|
category: str | None = None,
|
|
@@ -190,7 +203,7 @@ class R2IndexClient:
|
|
|
190
203
|
data = self._handle_response(response)
|
|
191
204
|
return FileListResponse.model_validate(data)
|
|
192
205
|
|
|
193
|
-
def
|
|
206
|
+
def create(self, data: FileCreateRequest) -> FileRecord:
|
|
194
207
|
"""
|
|
195
208
|
Create or upsert a file record.
|
|
196
209
|
|
|
@@ -204,7 +217,7 @@ class R2IndexClient:
|
|
|
204
217
|
result = self._handle_response(response)
|
|
205
218
|
return FileRecord.model_validate(result)
|
|
206
219
|
|
|
207
|
-
def
|
|
220
|
+
def get(self, file_id: str) -> FileRecord:
|
|
208
221
|
"""
|
|
209
222
|
Get a file by ID.
|
|
210
223
|
|
|
@@ -221,7 +234,7 @@ class R2IndexClient:
|
|
|
221
234
|
data = self._handle_response(response)
|
|
222
235
|
return FileRecord.model_validate(data)
|
|
223
236
|
|
|
224
|
-
def
|
|
237
|
+
def update(self, file_id: str, data: FileUpdateRequest) -> FileRecord:
|
|
225
238
|
"""
|
|
226
239
|
Update a file record.
|
|
227
240
|
|
|
@@ -239,7 +252,7 @@ class R2IndexClient:
|
|
|
239
252
|
result = self._handle_response(response)
|
|
240
253
|
return FileRecord.model_validate(result)
|
|
241
254
|
|
|
242
|
-
def
|
|
255
|
+
def delete(self, file_id: str) -> None:
|
|
243
256
|
"""
|
|
244
257
|
Delete a file by ID.
|
|
245
258
|
|
|
@@ -252,7 +265,7 @@ class R2IndexClient:
|
|
|
252
265
|
response = self._client.delete(f"/files/{file_id}")
|
|
253
266
|
self._handle_response(response)
|
|
254
267
|
|
|
255
|
-
def
|
|
268
|
+
def delete_by_tuple(self, remote_tuple: RemoteTuple) -> None:
|
|
256
269
|
"""
|
|
257
270
|
Delete a file by remote tuple.
|
|
258
271
|
|
|
@@ -271,7 +284,7 @@ class R2IndexClient:
|
|
|
271
284
|
response = self._client.delete("/files", params=params)
|
|
272
285
|
self._handle_response(response)
|
|
273
286
|
|
|
274
|
-
def
|
|
287
|
+
def get_by_tuple(self, remote_tuple: RemoteTuple) -> FileRecord:
|
|
275
288
|
"""
|
|
276
289
|
Get a file by remote tuple.
|
|
277
290
|
|
|
@@ -294,7 +307,7 @@ class R2IndexClient:
|
|
|
294
307
|
data = self._handle_response(response)
|
|
295
308
|
return FileRecord.model_validate(data)
|
|
296
309
|
|
|
297
|
-
def
|
|
310
|
+
def index(
|
|
298
311
|
self,
|
|
299
312
|
bucket: str | None = None,
|
|
300
313
|
category: str | None = None,
|
|
@@ -496,10 +509,10 @@ class R2IndexClient:
|
|
|
496
509
|
|
|
497
510
|
# High-Level Pipeline
|
|
498
511
|
|
|
499
|
-
def
|
|
512
|
+
def upload(
|
|
500
513
|
self,
|
|
501
|
-
local_path: str | Path,
|
|
502
514
|
bucket: str,
|
|
515
|
+
local_path: str | Path,
|
|
503
516
|
category: str,
|
|
504
517
|
entity: str,
|
|
505
518
|
remote_path: str,
|
|
@@ -520,8 +533,8 @@ class R2IndexClient:
|
|
|
520
533
|
3. Register with r2index API
|
|
521
534
|
|
|
522
535
|
Args:
|
|
523
|
-
local_path: Local path to the file to upload.
|
|
524
536
|
bucket: The S3/R2 bucket name.
|
|
537
|
+
local_path: Local path to the file to upload.
|
|
525
538
|
category: File category.
|
|
526
539
|
entity: File entity.
|
|
527
540
|
remote_path: Remote path in R2 (e.g., "/data/files").
|
|
@@ -552,6 +565,7 @@ class R2IndexClient:
|
|
|
552
565
|
# Step 3: Upload to R2
|
|
553
566
|
uploader.upload_file(
|
|
554
567
|
local_path,
|
|
568
|
+
bucket,
|
|
555
569
|
object_key,
|
|
556
570
|
content_type=content_type,
|
|
557
571
|
progress_callback=progress_callback,
|
|
@@ -575,14 +589,14 @@ class R2IndexClient:
|
|
|
575
589
|
sha512=checksums.sha512,
|
|
576
590
|
)
|
|
577
591
|
|
|
578
|
-
return self.
|
|
592
|
+
return self.create(create_request)
|
|
579
593
|
|
|
580
594
|
def _get_public_ip(self) -> str:
|
|
581
595
|
"""Fetch public IP address from checkip.amazonaws.com."""
|
|
582
596
|
response = httpx.get(CHECKIP_URL, timeout=10.0)
|
|
583
597
|
return response.text.strip()
|
|
584
598
|
|
|
585
|
-
def
|
|
599
|
+
def download(
|
|
586
600
|
self,
|
|
587
601
|
bucket: str,
|
|
588
602
|
object_id: str,
|
|
@@ -611,7 +625,7 @@ class R2IndexClient:
|
|
|
611
625
|
destination: Local path where the file will be saved.
|
|
612
626
|
ip_address: IP address of the downloader. If not provided, fetched
|
|
613
627
|
from checkip.amazonaws.com.
|
|
614
|
-
user_agent: User agent string. Defaults to "elaunira-r2index
|
|
628
|
+
user_agent: User agent string. Defaults to "elaunira-r2index/<version>".
|
|
615
629
|
progress_callback: Optional callback for download progress.
|
|
616
630
|
transfer_config: Optional transfer configuration for multipart/threading.
|
|
617
631
|
|
|
@@ -636,11 +650,12 @@ class R2IndexClient:
|
|
|
636
650
|
remote_tuple = _parse_object_id(object_id, bucket)
|
|
637
651
|
|
|
638
652
|
# Step 2: Get file record by tuple
|
|
639
|
-
file_record = self.
|
|
653
|
+
file_record = self.get_by_tuple(remote_tuple)
|
|
640
654
|
|
|
641
655
|
# Step 3: Build R2 object key and download
|
|
642
656
|
object_key = object_id.strip("/")
|
|
643
657
|
downloaded_path = storage.download_file(
|
|
658
|
+
bucket,
|
|
644
659
|
object_key,
|
|
645
660
|
destination,
|
|
646
661
|
progress_callback=progress_callback,
|
|
@@ -43,9 +43,8 @@ class R2Config:
|
|
|
43
43
|
"""Configuration for R2 storage."""
|
|
44
44
|
|
|
45
45
|
access_key_id: str
|
|
46
|
-
secret_access_key: str
|
|
47
46
|
endpoint_url: str
|
|
48
|
-
|
|
47
|
+
secret_access_key: str
|
|
49
48
|
region: str = "auto"
|
|
50
49
|
|
|
51
50
|
|
|
@@ -71,6 +70,7 @@ class R2Storage:
|
|
|
71
70
|
def upload_file(
|
|
72
71
|
self,
|
|
73
72
|
file_path: str | Path,
|
|
73
|
+
bucket: str,
|
|
74
74
|
object_key: str,
|
|
75
75
|
content_type: str | None = None,
|
|
76
76
|
progress_callback: Callable[[int], None] | None = None,
|
|
@@ -83,6 +83,7 @@ class R2Storage:
|
|
|
83
83
|
|
|
84
84
|
Args:
|
|
85
85
|
file_path: Path to the file to upload.
|
|
86
|
+
bucket: The R2 bucket name.
|
|
86
87
|
object_key: The key (path) to store the object under in R2.
|
|
87
88
|
content_type: Optional content type for the object.
|
|
88
89
|
progress_callback: Optional callback called with bytes uploaded so far.
|
|
@@ -118,7 +119,7 @@ class R2Storage:
|
|
|
118
119
|
try:
|
|
119
120
|
self._client.upload_file(
|
|
120
121
|
str(file_path),
|
|
121
|
-
|
|
122
|
+
bucket,
|
|
122
123
|
object_key,
|
|
123
124
|
Config=boto_transfer_config,
|
|
124
125
|
ExtraArgs=extra_args if extra_args else None,
|
|
@@ -129,33 +130,35 @@ class R2Storage:
|
|
|
129
130
|
|
|
130
131
|
return object_key
|
|
131
132
|
|
|
132
|
-
def delete_object(self, object_key: str) -> None:
|
|
133
|
+
def delete_object(self, bucket: str, object_key: str) -> None:
|
|
133
134
|
"""
|
|
134
135
|
Delete an object from R2.
|
|
135
136
|
|
|
136
137
|
Args:
|
|
138
|
+
bucket: The R2 bucket name.
|
|
137
139
|
object_key: The key of the object to delete.
|
|
138
140
|
|
|
139
141
|
Raises:
|
|
140
142
|
UploadError: If the deletion fails.
|
|
141
143
|
"""
|
|
142
144
|
try:
|
|
143
|
-
self._client.delete_object(Bucket=
|
|
145
|
+
self._client.delete_object(Bucket=bucket, Key=object_key)
|
|
144
146
|
except Exception as e:
|
|
145
147
|
raise UploadError(f"Failed to delete object from R2: {e}") from e
|
|
146
148
|
|
|
147
|
-
def object_exists(self, object_key: str) -> bool:
|
|
149
|
+
def object_exists(self, bucket: str, object_key: str) -> bool:
|
|
148
150
|
"""
|
|
149
151
|
Check if an object exists in R2.
|
|
150
152
|
|
|
151
153
|
Args:
|
|
154
|
+
bucket: The R2 bucket name.
|
|
152
155
|
object_key: The key of the object to check.
|
|
153
156
|
|
|
154
157
|
Returns:
|
|
155
158
|
True if the object exists, False otherwise.
|
|
156
159
|
"""
|
|
157
160
|
try:
|
|
158
|
-
self._client.head_object(Bucket=
|
|
161
|
+
self._client.head_object(Bucket=bucket, Key=object_key)
|
|
159
162
|
return True
|
|
160
163
|
except self._client.exceptions.ClientError as e:
|
|
161
164
|
if e.response["Error"]["Code"] == "404":
|
|
@@ -164,6 +167,7 @@ class R2Storage:
|
|
|
164
167
|
|
|
165
168
|
def download_file(
|
|
166
169
|
self,
|
|
170
|
+
bucket: str,
|
|
167
171
|
object_key: str,
|
|
168
172
|
file_path: str | Path,
|
|
169
173
|
progress_callback: Callable[[int], None] | None = None,
|
|
@@ -173,6 +177,7 @@ class R2Storage:
|
|
|
173
177
|
Download a file from R2.
|
|
174
178
|
|
|
175
179
|
Args:
|
|
180
|
+
bucket: The R2 bucket name.
|
|
176
181
|
object_key: The key (path) of the object in R2.
|
|
177
182
|
file_path: Local path where the file will be saved.
|
|
178
183
|
progress_callback: Optional callback called with bytes downloaded so far.
|
|
@@ -203,7 +208,7 @@ class R2Storage:
|
|
|
203
208
|
|
|
204
209
|
try:
|
|
205
210
|
self._client.download_file(
|
|
206
|
-
|
|
211
|
+
bucket,
|
|
207
212
|
object_key,
|
|
208
213
|
str(file_path),
|
|
209
214
|
Config=boto_transfer_config,
|
|
@@ -10,8 +10,8 @@ from elaunira.r2index import AsyncR2IndexClient, FileCreateRequest
|
|
|
10
10
|
def async_client():
|
|
11
11
|
"""Create a test async client."""
|
|
12
12
|
return AsyncR2IndexClient(
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
index_api_url="https://api.example.com",
|
|
14
|
+
index_api_token="test-token",
|
|
15
15
|
)
|
|
16
16
|
|
|
17
17
|
|
|
@@ -19,14 +19,14 @@ def async_client():
|
|
|
19
19
|
async def test_async_client_context_manager():
|
|
20
20
|
"""Test async client as context manager."""
|
|
21
21
|
async with AsyncR2IndexClient(
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
index_api_url="https://api.example.com",
|
|
23
|
+
index_api_token="test-token",
|
|
24
24
|
) as client:
|
|
25
25
|
assert client is not None
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
@pytest.mark.asyncio
|
|
29
|
-
async def
|
|
29
|
+
async def test_async_list(async_client: AsyncR2IndexClient, httpx_mock: HTTPXMock):
|
|
30
30
|
"""Test async listing files."""
|
|
31
31
|
httpx_mock.add_response(
|
|
32
32
|
url="https://api.example.com/files",
|
|
@@ -56,13 +56,13 @@ async def test_async_list_files(async_client: AsyncR2IndexClient, httpx_mock: HT
|
|
|
56
56
|
},
|
|
57
57
|
)
|
|
58
58
|
|
|
59
|
-
response = await async_client.
|
|
59
|
+
response = await async_client.list()
|
|
60
60
|
assert len(response.files) == 1
|
|
61
61
|
await async_client.close()
|
|
62
62
|
|
|
63
63
|
|
|
64
64
|
@pytest.mark.asyncio
|
|
65
|
-
async def
|
|
65
|
+
async def test_async_create(async_client: AsyncR2IndexClient, httpx_mock: HTTPXMock):
|
|
66
66
|
"""Test async creating a file record."""
|
|
67
67
|
httpx_mock.add_response(
|
|
68
68
|
url="https://api.example.com/files",
|
|
@@ -100,7 +100,7 @@ async def test_async_create_file(async_client: AsyncR2IndexClient, httpx_mock: H
|
|
|
100
100
|
sha256="ghi",
|
|
101
101
|
sha512="jkl",
|
|
102
102
|
)
|
|
103
|
-
record = await async_client.
|
|
103
|
+
record = await async_client.create(request)
|
|
104
104
|
assert record.id == "new-file"
|
|
105
105
|
await async_client.close()
|
|
106
106
|
|
|
@@ -16,8 +16,8 @@ from elaunira.r2index import (
|
|
|
16
16
|
def client():
|
|
17
17
|
"""Create a test client."""
|
|
18
18
|
return R2IndexClient(
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
index_api_url="https://api.example.com",
|
|
20
|
+
index_api_token="test-token",
|
|
21
21
|
)
|
|
22
22
|
|
|
23
23
|
|
|
@@ -29,13 +29,13 @@ def test_client_initialization(client: R2IndexClient):
|
|
|
29
29
|
def test_client_context_manager():
|
|
30
30
|
"""Test client as context manager."""
|
|
31
31
|
with R2IndexClient(
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
index_api_url="https://api.example.com",
|
|
33
|
+
index_api_token="test-token",
|
|
34
34
|
) as client:
|
|
35
35
|
assert client is not None
|
|
36
36
|
|
|
37
37
|
|
|
38
|
-
def
|
|
38
|
+
def test_list(client: R2IndexClient, httpx_mock: HTTPXMock):
|
|
39
39
|
"""Test listing files."""
|
|
40
40
|
httpx_mock.add_response(
|
|
41
41
|
url="https://api.example.com/files",
|
|
@@ -65,19 +65,19 @@ def test_list_files(client: R2IndexClient, httpx_mock: HTTPXMock):
|
|
|
65
65
|
},
|
|
66
66
|
)
|
|
67
67
|
|
|
68
|
-
response = client.
|
|
68
|
+
response = client.list()
|
|
69
69
|
assert len(response.files) == 1
|
|
70
70
|
assert response.files[0].id == "file1"
|
|
71
71
|
|
|
72
72
|
|
|
73
|
-
def
|
|
73
|
+
def test_list_with_filters(client: R2IndexClient, httpx_mock: HTTPXMock):
|
|
74
74
|
"""Test listing files with filters."""
|
|
75
75
|
httpx_mock.add_response(
|
|
76
76
|
url="https://api.example.com/files?category=software&entity=myapp&tags=release%2Cstable",
|
|
77
77
|
json={"files": [], "total": 0, "page": 1, "pageSize": 20},
|
|
78
78
|
)
|
|
79
79
|
|
|
80
|
-
response = client.
|
|
80
|
+
response = client.list(
|
|
81
81
|
category="software",
|
|
82
82
|
entity="myapp",
|
|
83
83
|
tags=["release", "stable"],
|
|
@@ -85,7 +85,7 @@ def test_list_files_with_filters(client: R2IndexClient, httpx_mock: HTTPXMock):
|
|
|
85
85
|
assert response.total == 0
|
|
86
86
|
|
|
87
87
|
|
|
88
|
-
def
|
|
88
|
+
def test_create(client: R2IndexClient, httpx_mock: HTTPXMock):
|
|
89
89
|
"""Test creating a file record."""
|
|
90
90
|
httpx_mock.add_response(
|
|
91
91
|
url="https://api.example.com/files",
|
|
@@ -123,11 +123,11 @@ def test_create_file(client: R2IndexClient, httpx_mock: HTTPXMock):
|
|
|
123
123
|
sha256="ghi",
|
|
124
124
|
sha512="jkl",
|
|
125
125
|
)
|
|
126
|
-
record = client.
|
|
126
|
+
record = client.create(request)
|
|
127
127
|
assert record.id == "new-file"
|
|
128
128
|
|
|
129
129
|
|
|
130
|
-
def
|
|
130
|
+
def test_get(client: R2IndexClient, httpx_mock: HTTPXMock):
|
|
131
131
|
"""Test getting a file by ID."""
|
|
132
132
|
httpx_mock.add_response(
|
|
133
133
|
url="https://api.example.com/files/file123",
|
|
@@ -150,11 +150,11 @@ def test_get_file(client: R2IndexClient, httpx_mock: HTTPXMock):
|
|
|
150
150
|
},
|
|
151
151
|
)
|
|
152
152
|
|
|
153
|
-
record = client.
|
|
153
|
+
record = client.get("file123")
|
|
154
154
|
assert record.id == "file123"
|
|
155
155
|
|
|
156
156
|
|
|
157
|
-
def
|
|
157
|
+
def test_get_not_found(client: R2IndexClient, httpx_mock: HTTPXMock):
|
|
158
158
|
"""Test 404 error handling."""
|
|
159
159
|
httpx_mock.add_response(
|
|
160
160
|
url="https://api.example.com/files/notfound",
|
|
@@ -163,7 +163,7 @@ def test_get_file_not_found(client: R2IndexClient, httpx_mock: HTTPXMock):
|
|
|
163
163
|
)
|
|
164
164
|
|
|
165
165
|
with pytest.raises(NotFoundError) as exc_info:
|
|
166
|
-
client.
|
|
166
|
+
client.get("notfound")
|
|
167
167
|
|
|
168
168
|
assert exc_info.value.status_code == 404
|
|
169
169
|
|
|
@@ -177,7 +177,7 @@ def test_authentication_error(client: R2IndexClient, httpx_mock: HTTPXMock):
|
|
|
177
177
|
)
|
|
178
178
|
|
|
179
179
|
with pytest.raises(AuthenticationError) as exc_info:
|
|
180
|
-
client.
|
|
180
|
+
client.list()
|
|
181
181
|
|
|
182
182
|
assert exc_info.value.status_code == 401
|
|
183
183
|
|
|
@@ -206,7 +206,7 @@ def test_validation_error(client: R2IndexClient, httpx_mock: HTTPXMock):
|
|
|
206
206
|
)
|
|
207
207
|
|
|
208
208
|
with pytest.raises(ValidationError) as exc_info:
|
|
209
|
-
client.
|
|
209
|
+
client.create(request)
|
|
210
210
|
|
|
211
211
|
assert exc_info.value.status_code == 400
|
|
212
212
|
|
|
@@ -11,7 +11,7 @@ from elaunira.r2index import (
|
|
|
11
11
|
RemoteTuple,
|
|
12
12
|
)
|
|
13
13
|
from elaunira.r2index.client import _parse_object_id
|
|
14
|
-
from elaunira.r2index.storage import
|
|
14
|
+
from elaunira.r2index.storage import R2TransferConfig
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
class TestParseObjectId:
|
|
@@ -74,18 +74,18 @@ class TestParseObjectId:
|
|
|
74
74
|
_parse_object_id("", "test-bucket")
|
|
75
75
|
|
|
76
76
|
|
|
77
|
-
class
|
|
78
|
-
"""Tests for
|
|
77
|
+
class TestGetByTuple:
|
|
78
|
+
"""Tests for get_by_tuple method."""
|
|
79
79
|
|
|
80
80
|
@pytest.fixture
|
|
81
81
|
def client(self):
|
|
82
82
|
"""Create a test client."""
|
|
83
83
|
return R2IndexClient(
|
|
84
|
-
|
|
85
|
-
|
|
84
|
+
index_api_url="https://api.example.com",
|
|
85
|
+
index_api_token="test-token",
|
|
86
86
|
)
|
|
87
87
|
|
|
88
|
-
def
|
|
88
|
+
def test_get_by_tuple(self, client: R2IndexClient, httpx_mock: HTTPXMock):
|
|
89
89
|
"""Test getting a file by remote tuple."""
|
|
90
90
|
httpx_mock.add_response(
|
|
91
91
|
url="https://api.example.com/files/by-tuple?bucket=test-bucket&remotePath=%2Freleases%2Fmyapp&remoteFilename=myapp.zip&remoteVersion=v1",
|
|
@@ -114,7 +114,7 @@ class TestGetFileByTuple:
|
|
|
114
114
|
remote_filename="myapp.zip",
|
|
115
115
|
remote_version="v1",
|
|
116
116
|
)
|
|
117
|
-
record = client.
|
|
117
|
+
record = client.get_by_tuple(remote_tuple)
|
|
118
118
|
|
|
119
119
|
assert record.id == "file123"
|
|
120
120
|
assert record.bucket == "test-bucket"
|
|
@@ -123,35 +123,31 @@ class TestGetFileByTuple:
|
|
|
123
123
|
assert record.remote_version == "v1"
|
|
124
124
|
|
|
125
125
|
|
|
126
|
-
class
|
|
127
|
-
"""Tests for
|
|
126
|
+
class TestDownload:
|
|
127
|
+
"""Tests for download method."""
|
|
128
128
|
|
|
129
129
|
@pytest.fixture
|
|
130
130
|
def client_with_r2(self):
|
|
131
131
|
"""Create a test client with R2 config."""
|
|
132
|
-
r2_config = R2Config(
|
|
133
|
-
access_key_id="test-key",
|
|
134
|
-
secret_access_key="test-secret",
|
|
135
|
-
endpoint_url="https://r2.example.com",
|
|
136
|
-
bucket="test-bucket",
|
|
137
|
-
)
|
|
138
132
|
return R2IndexClient(
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
133
|
+
index_api_url="https://api.example.com",
|
|
134
|
+
index_api_token="test-token",
|
|
135
|
+
r2_access_key_id="test-key",
|
|
136
|
+
r2_secret_access_key="test-secret",
|
|
137
|
+
r2_endpoint_url="https://r2.example.com",
|
|
142
138
|
)
|
|
143
139
|
|
|
144
|
-
def
|
|
140
|
+
def test_download_with_defaults(
|
|
145
141
|
self, client_with_r2: R2IndexClient, httpx_mock: HTTPXMock, tmp_path: Path
|
|
146
142
|
):
|
|
147
|
-
"""Test
|
|
143
|
+
"""Test download with default IP and user agent."""
|
|
148
144
|
# Mock checkip.amazonaws.com
|
|
149
145
|
httpx_mock.add_response(
|
|
150
146
|
url="https://checkip.amazonaws.com",
|
|
151
147
|
text="203.0.113.1\n",
|
|
152
148
|
)
|
|
153
149
|
|
|
154
|
-
# Mock
|
|
150
|
+
# Mock get_by_tuple
|
|
155
151
|
httpx_mock.add_response(
|
|
156
152
|
url="https://api.example.com/files/by-tuple?bucket=test-bucket&remotePath=%2Freleases%2Fmyapp&remoteFilename=myapp.zip&remoteVersion=v1",
|
|
157
153
|
json={
|
|
@@ -195,7 +191,7 @@ class TestDownloadAndRecord:
|
|
|
195
191
|
"download_file",
|
|
196
192
|
return_value=destination,
|
|
197
193
|
) as mock_download:
|
|
198
|
-
downloaded_path, file_record = client_with_r2.
|
|
194
|
+
downloaded_path, file_record = client_with_r2.download(
|
|
199
195
|
bucket="test-bucket",
|
|
200
196
|
object_id="/releases/myapp/v1/myapp.zip",
|
|
201
197
|
destination=str(destination),
|
|
@@ -205,11 +201,11 @@ class TestDownloadAndRecord:
|
|
|
205
201
|
assert downloaded_path == destination
|
|
206
202
|
assert file_record.id == "file123"
|
|
207
203
|
|
|
208
|
-
def
|
|
204
|
+
def test_download_with_explicit_ip_and_user_agent(
|
|
209
205
|
self, client_with_r2: R2IndexClient, httpx_mock: HTTPXMock, tmp_path: Path
|
|
210
206
|
):
|
|
211
|
-
"""Test
|
|
212
|
-
# Mock
|
|
207
|
+
"""Test download with explicit IP and user agent."""
|
|
208
|
+
# Mock get_by_tuple
|
|
213
209
|
httpx_mock.add_response(
|
|
214
210
|
url="https://api.example.com/files/by-tuple?bucket=test-bucket&remotePath=%2Freleases%2Fmyapp&remoteFilename=myapp.zip&remoteVersion=v1",
|
|
215
211
|
json={
|
|
@@ -253,7 +249,7 @@ class TestDownloadAndRecord:
|
|
|
253
249
|
"download_file",
|
|
254
250
|
return_value=destination,
|
|
255
251
|
):
|
|
256
|
-
downloaded_path, file_record = client_with_r2.
|
|
252
|
+
downloaded_path, file_record = client_with_r2.download(
|
|
257
253
|
bucket="test-bucket",
|
|
258
254
|
object_id="/releases/myapp/v1/myapp.zip",
|
|
259
255
|
destination=str(destination),
|
|
@@ -264,14 +260,14 @@ class TestDownloadAndRecord:
|
|
|
264
260
|
assert downloaded_path == destination
|
|
265
261
|
assert file_record.id == "file123"
|
|
266
262
|
|
|
267
|
-
def
|
|
263
|
+
def test_download_invalid_object_id(
|
|
268
264
|
self, client_with_r2: R2IndexClient, tmp_path: Path
|
|
269
265
|
):
|
|
270
|
-
"""Test
|
|
266
|
+
"""Test download with invalid object ID."""
|
|
271
267
|
destination = tmp_path / "file.zip"
|
|
272
268
|
|
|
273
269
|
with pytest.raises(ValueError) as exc_info:
|
|
274
|
-
client_with_r2.
|
|
270
|
+
client_with_r2.download(
|
|
275
271
|
bucket="test-bucket",
|
|
276
272
|
object_id="/invalid/path",
|
|
277
273
|
destination=str(destination),
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|