elaunira-r2index 0.1.0__py3-none-any.whl → 0.3.0__py3-none-any.whl
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/__init__.py +22 -49
- elaunira/r2index/async_client.py +205 -33
- elaunira/r2index/{async_uploader.py → async_storage.py} +80 -19
- elaunira/r2index/client.py +203 -32
- elaunira/r2index/exceptions.py +6 -0
- elaunira/r2index/models.py +5 -0
- elaunira/r2index/py.typed +0 -0
- elaunira/r2index/storage.py +232 -0
- elaunira_r2index-0.3.0.dist-info/METADATA +160 -0
- elaunira_r2index-0.3.0.dist-info/RECORD +13 -0
- elaunira/r2index/uploader.py +0 -147
- elaunira_r2index-0.1.0.dist-info/METADATA +0 -101
- elaunira_r2index-0.1.0.dist-info/RECORD +0 -12
- {elaunira_r2index-0.1.0.dist-info → elaunira_r2index-0.3.0.dist-info}/WHEEL +0 -0
elaunira/r2index/__init__.py
CHANGED
|
@@ -1,43 +1,11 @@
|
|
|
1
|
-
"""
|
|
2
|
-
R2 Index Python Library.
|
|
1
|
+
"""Python library for uploading and downloading files to/from Cloudflare R2 with the r2index API."""
|
|
3
2
|
|
|
4
|
-
|
|
5
|
-
with the r2index API. Supports both sync and async operations with streaming
|
|
6
|
-
checksums for memory-efficient handling of large files.
|
|
3
|
+
from importlib.metadata import version
|
|
7
4
|
|
|
8
|
-
|
|
9
|
-
from elaunira.r2index import R2IndexClient, R2Config
|
|
10
|
-
|
|
11
|
-
client = R2IndexClient(
|
|
12
|
-
api_url="https://r2index.example.com",
|
|
13
|
-
api_token="your-bearer-token",
|
|
14
|
-
r2_config=R2Config(
|
|
15
|
-
access_key_id="...",
|
|
16
|
-
secret_access_key="...",
|
|
17
|
-
endpoint_url="https://xxx.r2.cloudflarestorage.com",
|
|
18
|
-
bucket="your-bucket",
|
|
19
|
-
),
|
|
20
|
-
)
|
|
21
|
-
|
|
22
|
-
# Upload and register a file
|
|
23
|
-
record = client.upload_and_register(
|
|
24
|
-
file_path="./myfile.zip",
|
|
25
|
-
category="software",
|
|
26
|
-
entity="myapp",
|
|
27
|
-
remote_path="/releases",
|
|
28
|
-
remote_filename="myapp-1.0.0.zip",
|
|
29
|
-
remote_version="1.0.0",
|
|
30
|
-
)
|
|
31
|
-
|
|
32
|
-
Async usage:
|
|
33
|
-
from elaunira.r2index import AsyncR2IndexClient, R2Config
|
|
34
|
-
|
|
35
|
-
async with AsyncR2IndexClient(...) as client:
|
|
36
|
-
record = await client.upload_and_register(...)
|
|
37
|
-
"""
|
|
5
|
+
__version__ = version("elaunira-r2index")
|
|
38
6
|
|
|
39
7
|
from .async_client import AsyncR2IndexClient
|
|
40
|
-
from .
|
|
8
|
+
from .async_storage import AsyncR2Storage
|
|
41
9
|
from .checksums import (
|
|
42
10
|
ChecksumResult,
|
|
43
11
|
compute_checksums,
|
|
@@ -48,6 +16,7 @@ from .client import R2IndexClient
|
|
|
48
16
|
from .exceptions import (
|
|
49
17
|
AuthenticationError,
|
|
50
18
|
ConflictError,
|
|
19
|
+
DownloadError,
|
|
51
20
|
NotFoundError,
|
|
52
21
|
R2IndexError,
|
|
53
22
|
UploadError,
|
|
@@ -72,44 +41,48 @@ from .models import (
|
|
|
72
41
|
UserAgentEntry,
|
|
73
42
|
UserAgentsResponse,
|
|
74
43
|
)
|
|
75
|
-
from .
|
|
44
|
+
from .storage import R2Config, R2Storage, R2TransferConfig
|
|
76
45
|
|
|
77
46
|
__all__ = [
|
|
47
|
+
# Version
|
|
48
|
+
"__version__",
|
|
78
49
|
# Clients
|
|
79
|
-
"R2IndexClient",
|
|
80
50
|
"AsyncR2IndexClient",
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
"
|
|
51
|
+
"R2IndexClient",
|
|
52
|
+
# Storage
|
|
53
|
+
"AsyncR2Storage",
|
|
84
54
|
"R2Config",
|
|
55
|
+
"R2Storage",
|
|
56
|
+
"R2TransferConfig",
|
|
85
57
|
# Checksums
|
|
86
58
|
"ChecksumResult",
|
|
87
59
|
"compute_checksums",
|
|
88
60
|
"compute_checksums_async",
|
|
89
61
|
"compute_checksums_from_file_object",
|
|
90
62
|
# Exceptions
|
|
91
|
-
"R2IndexError",
|
|
92
63
|
"AuthenticationError",
|
|
93
|
-
"NotFoundError",
|
|
94
|
-
"ValidationError",
|
|
95
64
|
"ConflictError",
|
|
65
|
+
"DownloadError",
|
|
66
|
+
"NotFoundError",
|
|
67
|
+
"R2IndexError",
|
|
96
68
|
"UploadError",
|
|
69
|
+
"ValidationError",
|
|
97
70
|
# Models - File operations
|
|
98
|
-
"FileRecord",
|
|
99
71
|
"FileCreateRequest",
|
|
100
|
-
"FileUpdateRequest",
|
|
101
72
|
"FileListResponse",
|
|
73
|
+
"FileRecord",
|
|
74
|
+
"FileUpdateRequest",
|
|
102
75
|
"IndexEntry",
|
|
103
76
|
"RemoteTuple",
|
|
104
77
|
# Models - Downloads
|
|
105
78
|
"DownloadRecord",
|
|
106
79
|
"DownloadRecordRequest",
|
|
107
80
|
# Models - Analytics
|
|
108
|
-
"TimeseriesDataPoint",
|
|
109
|
-
"TimeseriesResponse",
|
|
110
|
-
"SummaryResponse",
|
|
111
81
|
"DownloadByIpEntry",
|
|
112
82
|
"DownloadsByIpResponse",
|
|
83
|
+
"SummaryResponse",
|
|
84
|
+
"TimeseriesDataPoint",
|
|
85
|
+
"TimeseriesResponse",
|
|
113
86
|
"UserAgentEntry",
|
|
114
87
|
"UserAgentsResponse",
|
|
115
88
|
# Models - Other
|
elaunira/r2index/async_client.py
CHANGED
|
@@ -7,7 +7,7 @@ from typing import Any
|
|
|
7
7
|
|
|
8
8
|
import httpx
|
|
9
9
|
|
|
10
|
-
from .
|
|
10
|
+
from .async_storage import AsyncR2Storage
|
|
11
11
|
from .checksums import compute_checksums_async
|
|
12
12
|
from .exceptions import (
|
|
13
13
|
AuthenticationError,
|
|
@@ -32,7 +32,48 @@ from .models import (
|
|
|
32
32
|
TimeseriesResponse,
|
|
33
33
|
UserAgentsResponse,
|
|
34
34
|
)
|
|
35
|
-
from .
|
|
35
|
+
from . import __version__
|
|
36
|
+
from .storage import R2Config, R2TransferConfig
|
|
37
|
+
|
|
38
|
+
CHECKIP_URL = "https://checkip.amazonaws.com"
|
|
39
|
+
DEFAULT_USER_AGENT = f"elaunira-r2index/{__version__}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _parse_object_id(object_id: str, bucket: str) -> RemoteTuple:
|
|
43
|
+
"""
|
|
44
|
+
Parse an object_id into remote_path, remote_version, and remote_filename.
|
|
45
|
+
|
|
46
|
+
Format: /path/to/object/version/filename.ext
|
|
47
|
+
- remote_filename: last component (filename.ext)
|
|
48
|
+
- remote_version: second-to-last component (version)
|
|
49
|
+
- remote_path: everything before that (/path/to/object)
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
object_id: Full object path like /releases/myapp/v1/myapp.zip
|
|
53
|
+
bucket: The S3/R2 bucket name.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
RemoteTuple with parsed components including bucket.
|
|
57
|
+
|
|
58
|
+
Raises:
|
|
59
|
+
ValueError: If object_id doesn't have enough components.
|
|
60
|
+
"""
|
|
61
|
+
parts = object_id.strip("/").split("/")
|
|
62
|
+
if len(parts) < 3:
|
|
63
|
+
raise ValueError(
|
|
64
|
+
f"object_id must have at least 3 components (path/version/filename), got: {object_id}"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
remote_filename = parts[-1]
|
|
68
|
+
remote_version = parts[-2]
|
|
69
|
+
remote_path = "/" + "/".join(parts[:-2])
|
|
70
|
+
|
|
71
|
+
return RemoteTuple(
|
|
72
|
+
bucket=bucket,
|
|
73
|
+
remote_path=remote_path,
|
|
74
|
+
remote_filename=remote_filename,
|
|
75
|
+
remote_version=remote_version,
|
|
76
|
+
)
|
|
36
77
|
|
|
37
78
|
|
|
38
79
|
class AsyncR2IndexClient:
|
|
@@ -40,29 +81,42 @@ class AsyncR2IndexClient:
|
|
|
40
81
|
|
|
41
82
|
def __init__(
|
|
42
83
|
self,
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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,
|
|
46
89
|
timeout: float = 30.0,
|
|
47
90
|
) -> None:
|
|
48
91
|
"""
|
|
49
92
|
Initialize the async R2Index client.
|
|
50
93
|
|
|
51
94
|
Args:
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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.
|
|
55
100
|
timeout: Request timeout in seconds.
|
|
56
101
|
"""
|
|
57
|
-
self.api_url =
|
|
58
|
-
self._token =
|
|
102
|
+
self.api_url = index_api_url.rstrip("/")
|
|
103
|
+
self._token = index_api_token
|
|
59
104
|
self._timeout = timeout
|
|
60
|
-
self.
|
|
61
|
-
|
|
105
|
+
self._storage: AsyncR2Storage | None = None
|
|
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
|
|
62
116
|
|
|
63
117
|
self._client = httpx.AsyncClient(
|
|
64
118
|
base_url=self.api_url,
|
|
65
|
-
headers={"Authorization": f"Bearer {
|
|
119
|
+
headers={"Authorization": f"Bearer {index_api_token}"},
|
|
66
120
|
timeout=timeout,
|
|
67
121
|
)
|
|
68
122
|
|
|
@@ -76,13 +130,13 @@ class AsyncR2IndexClient:
|
|
|
76
130
|
"""Close the HTTP client."""
|
|
77
131
|
await self._client.aclose()
|
|
78
132
|
|
|
79
|
-
def
|
|
133
|
+
def _get_storage(self) -> AsyncR2Storage:
|
|
80
134
|
"""Get or create the async R2 uploader."""
|
|
81
135
|
if self._r2_config is None:
|
|
82
136
|
raise R2IndexError("R2 configuration required for upload operations")
|
|
83
|
-
if self.
|
|
84
|
-
self.
|
|
85
|
-
return self.
|
|
137
|
+
if self._storage is None:
|
|
138
|
+
self._storage = AsyncR2Storage(self._r2_config)
|
|
139
|
+
return self._storage
|
|
86
140
|
|
|
87
141
|
def _handle_response(self, response: httpx.Response) -> Any:
|
|
88
142
|
"""Handle API response and raise appropriate exceptions."""
|
|
@@ -109,8 +163,9 @@ class AsyncR2IndexClient:
|
|
|
109
163
|
|
|
110
164
|
# File Operations
|
|
111
165
|
|
|
112
|
-
async def
|
|
166
|
+
async def list(
|
|
113
167
|
self,
|
|
168
|
+
bucket: str | None = None,
|
|
114
169
|
category: str | None = None,
|
|
115
170
|
entity: str | None = None,
|
|
116
171
|
tags: list[str] | None = None,
|
|
@@ -121,6 +176,7 @@ class AsyncR2IndexClient:
|
|
|
121
176
|
List files with optional filters.
|
|
122
177
|
|
|
123
178
|
Args:
|
|
179
|
+
bucket: Filter by bucket.
|
|
124
180
|
category: Filter by category.
|
|
125
181
|
entity: Filter by entity.
|
|
126
182
|
tags: Filter by tags.
|
|
@@ -131,6 +187,8 @@ class AsyncR2IndexClient:
|
|
|
131
187
|
FileListResponse with files and pagination info.
|
|
132
188
|
"""
|
|
133
189
|
params: dict[str, Any] = {}
|
|
190
|
+
if bucket:
|
|
191
|
+
params["bucket"] = bucket
|
|
134
192
|
if category:
|
|
135
193
|
params["category"] = category
|
|
136
194
|
if entity:
|
|
@@ -146,7 +204,7 @@ class AsyncR2IndexClient:
|
|
|
146
204
|
data = self._handle_response(response)
|
|
147
205
|
return FileListResponse.model_validate(data)
|
|
148
206
|
|
|
149
|
-
async def
|
|
207
|
+
async def create(self, data: FileCreateRequest) -> FileRecord:
|
|
150
208
|
"""
|
|
151
209
|
Create or upsert a file record.
|
|
152
210
|
|
|
@@ -160,7 +218,7 @@ class AsyncR2IndexClient:
|
|
|
160
218
|
result = self._handle_response(response)
|
|
161
219
|
return FileRecord.model_validate(result)
|
|
162
220
|
|
|
163
|
-
async def
|
|
221
|
+
async def get(self, file_id: str) -> FileRecord:
|
|
164
222
|
"""
|
|
165
223
|
Get a file by ID.
|
|
166
224
|
|
|
@@ -177,7 +235,7 @@ class AsyncR2IndexClient:
|
|
|
177
235
|
data = self._handle_response(response)
|
|
178
236
|
return FileRecord.model_validate(data)
|
|
179
237
|
|
|
180
|
-
async def
|
|
238
|
+
async def update(self, file_id: str, data: FileUpdateRequest) -> FileRecord:
|
|
181
239
|
"""
|
|
182
240
|
Update a file record.
|
|
183
241
|
|
|
@@ -195,7 +253,7 @@ class AsyncR2IndexClient:
|
|
|
195
253
|
result = self._handle_response(response)
|
|
196
254
|
return FileRecord.model_validate(result)
|
|
197
255
|
|
|
198
|
-
async def
|
|
256
|
+
async def delete(self, file_id: str) -> None:
|
|
199
257
|
"""
|
|
200
258
|
Delete a file by ID.
|
|
201
259
|
|
|
@@ -208,17 +266,18 @@ class AsyncR2IndexClient:
|
|
|
208
266
|
response = await self._client.delete(f"/files/{file_id}")
|
|
209
267
|
self._handle_response(response)
|
|
210
268
|
|
|
211
|
-
async def
|
|
269
|
+
async def delete_by_tuple(self, remote_tuple: RemoteTuple) -> None:
|
|
212
270
|
"""
|
|
213
271
|
Delete a file by remote tuple.
|
|
214
272
|
|
|
215
273
|
Args:
|
|
216
|
-
remote_tuple: The remote path, filename, and version.
|
|
274
|
+
remote_tuple: The bucket, remote path, filename, and version.
|
|
217
275
|
|
|
218
276
|
Raises:
|
|
219
277
|
NotFoundError: If the file is not found.
|
|
220
278
|
"""
|
|
221
279
|
params = {
|
|
280
|
+
"bucket": remote_tuple.bucket,
|
|
222
281
|
"remotePath": remote_tuple.remote_path,
|
|
223
282
|
"remoteFilename": remote_tuple.remote_filename,
|
|
224
283
|
"remoteVersion": remote_tuple.remote_version,
|
|
@@ -226,8 +285,32 @@ class AsyncR2IndexClient:
|
|
|
226
285
|
response = await self._client.delete("/files", params=params)
|
|
227
286
|
self._handle_response(response)
|
|
228
287
|
|
|
229
|
-
async def
|
|
288
|
+
async def get_by_tuple(self, remote_tuple: RemoteTuple) -> FileRecord:
|
|
289
|
+
"""
|
|
290
|
+
Get a file by remote tuple.
|
|
291
|
+
|
|
292
|
+
Args:
|
|
293
|
+
remote_tuple: The bucket, remote path, filename, and version.
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
The FileRecord.
|
|
297
|
+
|
|
298
|
+
Raises:
|
|
299
|
+
NotFoundError: If the file is not found.
|
|
300
|
+
"""
|
|
301
|
+
params = {
|
|
302
|
+
"bucket": remote_tuple.bucket,
|
|
303
|
+
"remotePath": remote_tuple.remote_path,
|
|
304
|
+
"remoteFilename": remote_tuple.remote_filename,
|
|
305
|
+
"remoteVersion": remote_tuple.remote_version,
|
|
306
|
+
}
|
|
307
|
+
response = await self._client.get("/files/by-tuple", params=params)
|
|
308
|
+
data = self._handle_response(response)
|
|
309
|
+
return FileRecord.model_validate(data)
|
|
310
|
+
|
|
311
|
+
async def index(
|
|
230
312
|
self,
|
|
313
|
+
bucket: str | None = None,
|
|
231
314
|
category: str | None = None,
|
|
232
315
|
entity: str | None = None,
|
|
233
316
|
tags: list[str] | None = None,
|
|
@@ -236,6 +319,7 @@ class AsyncR2IndexClient:
|
|
|
236
319
|
Get file index (lightweight listing).
|
|
237
320
|
|
|
238
321
|
Args:
|
|
322
|
+
bucket: Filter by bucket.
|
|
239
323
|
category: Filter by category.
|
|
240
324
|
entity: Filter by entity.
|
|
241
325
|
tags: Filter by tags.
|
|
@@ -244,6 +328,8 @@ class AsyncR2IndexClient:
|
|
|
244
328
|
List of IndexEntry objects.
|
|
245
329
|
"""
|
|
246
330
|
params: dict[str, Any] = {}
|
|
331
|
+
if bucket:
|
|
332
|
+
params["bucket"] = bucket
|
|
247
333
|
if category:
|
|
248
334
|
params["category"] = category
|
|
249
335
|
if entity:
|
|
@@ -424,9 +510,10 @@ class AsyncR2IndexClient:
|
|
|
424
510
|
|
|
425
511
|
# High-Level Pipeline
|
|
426
512
|
|
|
427
|
-
async def
|
|
513
|
+
async def upload(
|
|
428
514
|
self,
|
|
429
|
-
|
|
515
|
+
bucket: str,
|
|
516
|
+
local_path: str | Path,
|
|
430
517
|
category: str,
|
|
431
518
|
entity: str,
|
|
432
519
|
remote_path: str,
|
|
@@ -447,7 +534,8 @@ class AsyncR2IndexClient:
|
|
|
447
534
|
3. Register with r2index API
|
|
448
535
|
|
|
449
536
|
Args:
|
|
450
|
-
|
|
537
|
+
bucket: The S3/R2 bucket name.
|
|
538
|
+
local_path: Local path to the file to upload.
|
|
451
539
|
category: File category.
|
|
452
540
|
entity: File entity.
|
|
453
541
|
remote_path: Remote path in R2 (e.g., "/data/files").
|
|
@@ -466,18 +554,19 @@ class AsyncR2IndexClient:
|
|
|
466
554
|
R2IndexError: If R2 config is not provided.
|
|
467
555
|
UploadError: If upload fails.
|
|
468
556
|
"""
|
|
469
|
-
|
|
470
|
-
uploader = self.
|
|
557
|
+
local_path = Path(local_path)
|
|
558
|
+
uploader = self._get_storage()
|
|
471
559
|
|
|
472
560
|
# Step 1: Compute checksums
|
|
473
|
-
checksums = await compute_checksums_async(
|
|
561
|
+
checksums = await compute_checksums_async(local_path)
|
|
474
562
|
|
|
475
563
|
# Step 2: Build R2 object key
|
|
476
564
|
object_key = f"{remote_path.strip('/')}/{remote_filename}"
|
|
477
565
|
|
|
478
566
|
# Step 3: Upload to R2
|
|
479
567
|
await uploader.upload_file(
|
|
480
|
-
|
|
568
|
+
local_path,
|
|
569
|
+
bucket,
|
|
481
570
|
object_key,
|
|
482
571
|
content_type=content_type,
|
|
483
572
|
progress_callback=progress_callback,
|
|
@@ -485,6 +574,7 @@ class AsyncR2IndexClient:
|
|
|
485
574
|
|
|
486
575
|
# Step 4: Register with API
|
|
487
576
|
create_request = FileCreateRequest(
|
|
577
|
+
bucket=bucket,
|
|
488
578
|
category=category,
|
|
489
579
|
entity=entity,
|
|
490
580
|
remote_path=remote_path,
|
|
@@ -500,4 +590,86 @@ class AsyncR2IndexClient:
|
|
|
500
590
|
sha512=checksums.sha512,
|
|
501
591
|
)
|
|
502
592
|
|
|
503
|
-
return await self.
|
|
593
|
+
return await self.create(create_request)
|
|
594
|
+
|
|
595
|
+
async def _get_public_ip(self) -> str:
|
|
596
|
+
"""Fetch public IP address from checkip.amazonaws.com."""
|
|
597
|
+
async with httpx.AsyncClient() as client:
|
|
598
|
+
response = await client.get(CHECKIP_URL, timeout=10.0)
|
|
599
|
+
return response.text.strip()
|
|
600
|
+
|
|
601
|
+
async def download(
|
|
602
|
+
self,
|
|
603
|
+
bucket: str,
|
|
604
|
+
object_id: str,
|
|
605
|
+
destination: str | Path,
|
|
606
|
+
ip_address: str | None = None,
|
|
607
|
+
user_agent: str | None = None,
|
|
608
|
+
progress_callback: Callable[[int], None] | None = None,
|
|
609
|
+
transfer_config: R2TransferConfig | None = None,
|
|
610
|
+
) -> tuple[Path, FileRecord]:
|
|
611
|
+
"""
|
|
612
|
+
Download a file from R2 and record the download in the index asynchronously.
|
|
613
|
+
|
|
614
|
+
This is a convenience method that performs:
|
|
615
|
+
1. Parse object_id into remote_path, remote_version, remote_filename
|
|
616
|
+
2. Fetch file record from the API using these components
|
|
617
|
+
3. Download the file from R2
|
|
618
|
+
4. Record the download in the index for analytics
|
|
619
|
+
|
|
620
|
+
Args:
|
|
621
|
+
bucket: The S3/R2 bucket name.
|
|
622
|
+
object_id: Full S3 object path in format: /path/to/object/version/filename
|
|
623
|
+
Example: /releases/myapp/v1/myapp.zip
|
|
624
|
+
- remote_path: /releases/myapp
|
|
625
|
+
- remote_version: v1
|
|
626
|
+
- remote_filename: myapp.zip
|
|
627
|
+
destination: Local path where the file will be saved.
|
|
628
|
+
ip_address: IP address of the downloader. If not provided, fetched
|
|
629
|
+
from checkip.amazonaws.com.
|
|
630
|
+
user_agent: User agent string. Defaults to "elaunira-r2index/<version>".
|
|
631
|
+
progress_callback: Optional callback for download progress.
|
|
632
|
+
transfer_config: Optional transfer configuration for multipart/threading.
|
|
633
|
+
|
|
634
|
+
Returns:
|
|
635
|
+
A tuple of (downloaded file path, file record).
|
|
636
|
+
|
|
637
|
+
Raises:
|
|
638
|
+
R2IndexError: If R2 config is not provided.
|
|
639
|
+
ValueError: If object_id format is invalid.
|
|
640
|
+
NotFoundError: If the file is not found in the index.
|
|
641
|
+
DownloadError: If download fails.
|
|
642
|
+
"""
|
|
643
|
+
storage = self._get_storage()
|
|
644
|
+
|
|
645
|
+
# Resolve defaults
|
|
646
|
+
if ip_address is None:
|
|
647
|
+
ip_address = await self._get_public_ip()
|
|
648
|
+
if user_agent is None:
|
|
649
|
+
user_agent = DEFAULT_USER_AGENT
|
|
650
|
+
|
|
651
|
+
# Step 1: Parse object_id into components
|
|
652
|
+
remote_tuple = _parse_object_id(object_id, bucket)
|
|
653
|
+
|
|
654
|
+
# Step 2: Get file record by tuple
|
|
655
|
+
file_record = await self.get_by_tuple(remote_tuple)
|
|
656
|
+
|
|
657
|
+
# Step 3: Build R2 object key and download
|
|
658
|
+
object_key = object_id.strip("/")
|
|
659
|
+
downloaded_path = await storage.download_file(
|
|
660
|
+
bucket,
|
|
661
|
+
object_key,
|
|
662
|
+
destination,
|
|
663
|
+
progress_callback=progress_callback,
|
|
664
|
+
transfer_config=transfer_config,
|
|
665
|
+
)
|
|
666
|
+
|
|
667
|
+
# Step 4: Record the download
|
|
668
|
+
download_request = DownloadRecordRequest(
|
|
669
|
+
file_id=file_record.id,
|
|
670
|
+
ip_address=ip_address,
|
|
671
|
+
user_agent=user_agent,
|
|
672
|
+
)
|
|
673
|
+
await self.record_download(download_request)
|
|
674
|
+
|
|
675
|
+
return downloaded_path, file_record
|