elaunira-r2index 0.1.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/__init__.py +1 -0
- elaunira/r2index/__init__.py +118 -0
- elaunira/r2index/async_client.py +503 -0
- elaunira/r2index/async_uploader.py +149 -0
- elaunira/r2index/checksums.py +127 -0
- elaunira/r2index/client.py +502 -0
- elaunira/r2index/exceptions.py +40 -0
- elaunira/r2index/models.py +204 -0
- elaunira/r2index/uploader.py +147 -0
- elaunira_r2index-0.1.0.dist-info/METADATA +101 -0
- elaunira_r2index-0.1.0.dist-info/RECORD +12 -0
- elaunira_r2index-0.1.0.dist-info/WHEEL +4 -0
elaunira/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Elaunira namespace package."""
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""
|
|
2
|
+
R2 Index Python Library.
|
|
3
|
+
|
|
4
|
+
A Python library for uploading files to Cloudflare R2 and registering them
|
|
5
|
+
with the r2index API. Supports both sync and async operations with streaming
|
|
6
|
+
checksums for memory-efficient handling of large files.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
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
|
+
"""
|
|
38
|
+
|
|
39
|
+
from .async_client import AsyncR2IndexClient
|
|
40
|
+
from .async_uploader import AsyncR2Uploader
|
|
41
|
+
from .checksums import (
|
|
42
|
+
ChecksumResult,
|
|
43
|
+
compute_checksums,
|
|
44
|
+
compute_checksums_async,
|
|
45
|
+
compute_checksums_from_file_object,
|
|
46
|
+
)
|
|
47
|
+
from .client import R2IndexClient
|
|
48
|
+
from .exceptions import (
|
|
49
|
+
AuthenticationError,
|
|
50
|
+
ConflictError,
|
|
51
|
+
NotFoundError,
|
|
52
|
+
R2IndexError,
|
|
53
|
+
UploadError,
|
|
54
|
+
ValidationError,
|
|
55
|
+
)
|
|
56
|
+
from .models import (
|
|
57
|
+
CleanupResponse,
|
|
58
|
+
DownloadByIpEntry,
|
|
59
|
+
DownloadRecord,
|
|
60
|
+
DownloadRecordRequest,
|
|
61
|
+
DownloadsByIpResponse,
|
|
62
|
+
FileCreateRequest,
|
|
63
|
+
FileListResponse,
|
|
64
|
+
FileRecord,
|
|
65
|
+
FileUpdateRequest,
|
|
66
|
+
HealthResponse,
|
|
67
|
+
IndexEntry,
|
|
68
|
+
RemoteTuple,
|
|
69
|
+
SummaryResponse,
|
|
70
|
+
TimeseriesDataPoint,
|
|
71
|
+
TimeseriesResponse,
|
|
72
|
+
UserAgentEntry,
|
|
73
|
+
UserAgentsResponse,
|
|
74
|
+
)
|
|
75
|
+
from .uploader import R2Config, R2Uploader
|
|
76
|
+
|
|
77
|
+
__all__ = [
|
|
78
|
+
# Clients
|
|
79
|
+
"R2IndexClient",
|
|
80
|
+
"AsyncR2IndexClient",
|
|
81
|
+
# Uploaders
|
|
82
|
+
"R2Uploader",
|
|
83
|
+
"AsyncR2Uploader",
|
|
84
|
+
"R2Config",
|
|
85
|
+
# Checksums
|
|
86
|
+
"ChecksumResult",
|
|
87
|
+
"compute_checksums",
|
|
88
|
+
"compute_checksums_async",
|
|
89
|
+
"compute_checksums_from_file_object",
|
|
90
|
+
# Exceptions
|
|
91
|
+
"R2IndexError",
|
|
92
|
+
"AuthenticationError",
|
|
93
|
+
"NotFoundError",
|
|
94
|
+
"ValidationError",
|
|
95
|
+
"ConflictError",
|
|
96
|
+
"UploadError",
|
|
97
|
+
# Models - File operations
|
|
98
|
+
"FileRecord",
|
|
99
|
+
"FileCreateRequest",
|
|
100
|
+
"FileUpdateRequest",
|
|
101
|
+
"FileListResponse",
|
|
102
|
+
"IndexEntry",
|
|
103
|
+
"RemoteTuple",
|
|
104
|
+
# Models - Downloads
|
|
105
|
+
"DownloadRecord",
|
|
106
|
+
"DownloadRecordRequest",
|
|
107
|
+
# Models - Analytics
|
|
108
|
+
"TimeseriesDataPoint",
|
|
109
|
+
"TimeseriesResponse",
|
|
110
|
+
"SummaryResponse",
|
|
111
|
+
"DownloadByIpEntry",
|
|
112
|
+
"DownloadsByIpResponse",
|
|
113
|
+
"UserAgentEntry",
|
|
114
|
+
"UserAgentsResponse",
|
|
115
|
+
# Models - Other
|
|
116
|
+
"CleanupResponse",
|
|
117
|
+
"HealthResponse",
|
|
118
|
+
]
|
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
"""Asynchronous R2Index API client."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from .async_uploader import AsyncR2Uploader
|
|
11
|
+
from .checksums import compute_checksums_async
|
|
12
|
+
from .exceptions import (
|
|
13
|
+
AuthenticationError,
|
|
14
|
+
ConflictError,
|
|
15
|
+
NotFoundError,
|
|
16
|
+
R2IndexError,
|
|
17
|
+
ValidationError,
|
|
18
|
+
)
|
|
19
|
+
from .models import (
|
|
20
|
+
CleanupResponse,
|
|
21
|
+
DownloadRecord,
|
|
22
|
+
DownloadRecordRequest,
|
|
23
|
+
DownloadsByIpResponse,
|
|
24
|
+
FileCreateRequest,
|
|
25
|
+
FileListResponse,
|
|
26
|
+
FileRecord,
|
|
27
|
+
FileUpdateRequest,
|
|
28
|
+
HealthResponse,
|
|
29
|
+
IndexEntry,
|
|
30
|
+
RemoteTuple,
|
|
31
|
+
SummaryResponse,
|
|
32
|
+
TimeseriesResponse,
|
|
33
|
+
UserAgentsResponse,
|
|
34
|
+
)
|
|
35
|
+
from .uploader import R2Config
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AsyncR2IndexClient:
|
|
39
|
+
"""Asynchronous client for the r2index API."""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
api_url: str,
|
|
44
|
+
api_token: str,
|
|
45
|
+
r2_config: R2Config | None = None,
|
|
46
|
+
timeout: float = 30.0,
|
|
47
|
+
) -> None:
|
|
48
|
+
"""
|
|
49
|
+
Initialize the async R2Index client.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
api_url: Base URL of the r2index API.
|
|
53
|
+
api_token: Bearer token for authentication.
|
|
54
|
+
r2_config: Optional R2 configuration for upload operations.
|
|
55
|
+
timeout: Request timeout in seconds.
|
|
56
|
+
"""
|
|
57
|
+
self.api_url = api_url.rstrip("/")
|
|
58
|
+
self._token = api_token
|
|
59
|
+
self._timeout = timeout
|
|
60
|
+
self._r2_config = r2_config
|
|
61
|
+
self._uploader: AsyncR2Uploader | None = None
|
|
62
|
+
|
|
63
|
+
self._client = httpx.AsyncClient(
|
|
64
|
+
base_url=self.api_url,
|
|
65
|
+
headers={"Authorization": f"Bearer {api_token}"},
|
|
66
|
+
timeout=timeout,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
async def __aenter__(self) -> "AsyncR2IndexClient":
|
|
70
|
+
return self
|
|
71
|
+
|
|
72
|
+
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
73
|
+
await self.close()
|
|
74
|
+
|
|
75
|
+
async def close(self) -> None:
|
|
76
|
+
"""Close the HTTP client."""
|
|
77
|
+
await self._client.aclose()
|
|
78
|
+
|
|
79
|
+
def _get_uploader(self) -> AsyncR2Uploader:
|
|
80
|
+
"""Get or create the async R2 uploader."""
|
|
81
|
+
if self._r2_config is None:
|
|
82
|
+
raise R2IndexError("R2 configuration required for upload operations")
|
|
83
|
+
if self._uploader is None:
|
|
84
|
+
self._uploader = AsyncR2Uploader(self._r2_config)
|
|
85
|
+
return self._uploader
|
|
86
|
+
|
|
87
|
+
def _handle_response(self, response: httpx.Response) -> Any:
|
|
88
|
+
"""Handle API response and raise appropriate exceptions."""
|
|
89
|
+
if response.status_code == 200 or response.status_code == 201:
|
|
90
|
+
return response.json()
|
|
91
|
+
|
|
92
|
+
status = response.status_code
|
|
93
|
+
try:
|
|
94
|
+
error_data = response.json()
|
|
95
|
+
message = error_data.get("error", response.text)
|
|
96
|
+
except Exception:
|
|
97
|
+
message = response.text
|
|
98
|
+
|
|
99
|
+
if status == 401 or status == 403:
|
|
100
|
+
raise AuthenticationError(message, status)
|
|
101
|
+
elif status == 404:
|
|
102
|
+
raise NotFoundError(message, status)
|
|
103
|
+
elif status == 400:
|
|
104
|
+
raise ValidationError(message, status)
|
|
105
|
+
elif status == 409:
|
|
106
|
+
raise ConflictError(message, status)
|
|
107
|
+
else:
|
|
108
|
+
raise R2IndexError(message, status)
|
|
109
|
+
|
|
110
|
+
# File Operations
|
|
111
|
+
|
|
112
|
+
async def list_files(
|
|
113
|
+
self,
|
|
114
|
+
category: str | None = None,
|
|
115
|
+
entity: str | None = None,
|
|
116
|
+
tags: list[str] | None = None,
|
|
117
|
+
page: int | None = None,
|
|
118
|
+
page_size: int | None = None,
|
|
119
|
+
) -> FileListResponse:
|
|
120
|
+
"""
|
|
121
|
+
List files with optional filters.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
category: Filter by category.
|
|
125
|
+
entity: Filter by entity.
|
|
126
|
+
tags: Filter by tags.
|
|
127
|
+
page: Page number (1-indexed).
|
|
128
|
+
page_size: Number of items per page.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
FileListResponse with files and pagination info.
|
|
132
|
+
"""
|
|
133
|
+
params: dict[str, Any] = {}
|
|
134
|
+
if category:
|
|
135
|
+
params["category"] = category
|
|
136
|
+
if entity:
|
|
137
|
+
params["entity"] = entity
|
|
138
|
+
if tags:
|
|
139
|
+
params["tags"] = ",".join(tags)
|
|
140
|
+
if page:
|
|
141
|
+
params["page"] = page
|
|
142
|
+
if page_size:
|
|
143
|
+
params["pageSize"] = page_size
|
|
144
|
+
|
|
145
|
+
response = await self._client.get("/files", params=params)
|
|
146
|
+
data = self._handle_response(response)
|
|
147
|
+
return FileListResponse.model_validate(data)
|
|
148
|
+
|
|
149
|
+
async def create_file(self, data: FileCreateRequest) -> FileRecord:
|
|
150
|
+
"""
|
|
151
|
+
Create or upsert a file record.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
data: File creation request data.
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
The created or updated FileRecord.
|
|
158
|
+
"""
|
|
159
|
+
response = await self._client.post("/files", json=data.model_dump(by_alias=True))
|
|
160
|
+
result = self._handle_response(response)
|
|
161
|
+
return FileRecord.model_validate(result)
|
|
162
|
+
|
|
163
|
+
async def get_file(self, file_id: str) -> FileRecord:
|
|
164
|
+
"""
|
|
165
|
+
Get a file by ID.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
file_id: The file ID.
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
The FileRecord.
|
|
172
|
+
|
|
173
|
+
Raises:
|
|
174
|
+
NotFoundError: If the file is not found.
|
|
175
|
+
"""
|
|
176
|
+
response = await self._client.get(f"/files/{file_id}")
|
|
177
|
+
data = self._handle_response(response)
|
|
178
|
+
return FileRecord.model_validate(data)
|
|
179
|
+
|
|
180
|
+
async def update_file(self, file_id: str, data: FileUpdateRequest) -> FileRecord:
|
|
181
|
+
"""
|
|
182
|
+
Update a file record.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
file_id: The file ID to update.
|
|
186
|
+
data: Fields to update.
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
The updated FileRecord.
|
|
190
|
+
"""
|
|
191
|
+
response = await self._client.put(
|
|
192
|
+
f"/files/{file_id}",
|
|
193
|
+
json=data.model_dump(exclude_none=True, by_alias=True),
|
|
194
|
+
)
|
|
195
|
+
result = self._handle_response(response)
|
|
196
|
+
return FileRecord.model_validate(result)
|
|
197
|
+
|
|
198
|
+
async def delete_file(self, file_id: str) -> None:
|
|
199
|
+
"""
|
|
200
|
+
Delete a file by ID.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
file_id: The file ID to delete.
|
|
204
|
+
|
|
205
|
+
Raises:
|
|
206
|
+
NotFoundError: If the file is not found.
|
|
207
|
+
"""
|
|
208
|
+
response = await self._client.delete(f"/files/{file_id}")
|
|
209
|
+
self._handle_response(response)
|
|
210
|
+
|
|
211
|
+
async def delete_file_by_tuple(self, remote_tuple: RemoteTuple) -> None:
|
|
212
|
+
"""
|
|
213
|
+
Delete a file by remote tuple.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
remote_tuple: The remote path, filename, and version.
|
|
217
|
+
|
|
218
|
+
Raises:
|
|
219
|
+
NotFoundError: If the file is not found.
|
|
220
|
+
"""
|
|
221
|
+
params = {
|
|
222
|
+
"remotePath": remote_tuple.remote_path,
|
|
223
|
+
"remoteFilename": remote_tuple.remote_filename,
|
|
224
|
+
"remoteVersion": remote_tuple.remote_version,
|
|
225
|
+
}
|
|
226
|
+
response = await self._client.delete("/files", params=params)
|
|
227
|
+
self._handle_response(response)
|
|
228
|
+
|
|
229
|
+
async def get_index(
|
|
230
|
+
self,
|
|
231
|
+
category: str | None = None,
|
|
232
|
+
entity: str | None = None,
|
|
233
|
+
tags: list[str] | None = None,
|
|
234
|
+
) -> list[IndexEntry]:
|
|
235
|
+
"""
|
|
236
|
+
Get file index (lightweight listing).
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
category: Filter by category.
|
|
240
|
+
entity: Filter by entity.
|
|
241
|
+
tags: Filter by tags.
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
List of IndexEntry objects.
|
|
245
|
+
"""
|
|
246
|
+
params: dict[str, Any] = {}
|
|
247
|
+
if category:
|
|
248
|
+
params["category"] = category
|
|
249
|
+
if entity:
|
|
250
|
+
params["entity"] = entity
|
|
251
|
+
if tags:
|
|
252
|
+
params["tags"] = ",".join(tags)
|
|
253
|
+
|
|
254
|
+
response = await self._client.get("/files/index", params=params)
|
|
255
|
+
data = self._handle_response(response)
|
|
256
|
+
return [IndexEntry.model_validate(item) for item in data]
|
|
257
|
+
|
|
258
|
+
# Download Tracking
|
|
259
|
+
|
|
260
|
+
async def record_download(self, data: DownloadRecordRequest) -> DownloadRecord:
|
|
261
|
+
"""
|
|
262
|
+
Record a file download.
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
data: Download record data.
|
|
266
|
+
|
|
267
|
+
Returns:
|
|
268
|
+
The created DownloadRecord.
|
|
269
|
+
"""
|
|
270
|
+
response = await self._client.post("/downloads", json=data.model_dump(by_alias=True))
|
|
271
|
+
result = self._handle_response(response)
|
|
272
|
+
return DownloadRecord.model_validate(result)
|
|
273
|
+
|
|
274
|
+
# Analytics
|
|
275
|
+
|
|
276
|
+
async def get_timeseries(
|
|
277
|
+
self,
|
|
278
|
+
start: datetime,
|
|
279
|
+
end: datetime,
|
|
280
|
+
granularity: str = "day",
|
|
281
|
+
file_id: str | None = None,
|
|
282
|
+
category: str | None = None,
|
|
283
|
+
entity: str | None = None,
|
|
284
|
+
) -> TimeseriesResponse:
|
|
285
|
+
"""
|
|
286
|
+
Get download timeseries analytics.
|
|
287
|
+
|
|
288
|
+
Args:
|
|
289
|
+
start: Start datetime.
|
|
290
|
+
end: End datetime.
|
|
291
|
+
granularity: Time granularity (hour, day, week, month).
|
|
292
|
+
file_id: Optional file ID filter.
|
|
293
|
+
category: Optional category filter.
|
|
294
|
+
entity: Optional entity filter.
|
|
295
|
+
|
|
296
|
+
Returns:
|
|
297
|
+
TimeseriesResponse with data points.
|
|
298
|
+
"""
|
|
299
|
+
params: dict[str, Any] = {
|
|
300
|
+
"start": start.isoformat(),
|
|
301
|
+
"end": end.isoformat(),
|
|
302
|
+
"granularity": granularity,
|
|
303
|
+
}
|
|
304
|
+
if file_id:
|
|
305
|
+
params["fileId"] = file_id
|
|
306
|
+
if category:
|
|
307
|
+
params["category"] = category
|
|
308
|
+
if entity:
|
|
309
|
+
params["entity"] = entity
|
|
310
|
+
|
|
311
|
+
response = await self._client.get("/analytics/timeseries", params=params)
|
|
312
|
+
data = self._handle_response(response)
|
|
313
|
+
return TimeseriesResponse.model_validate(data)
|
|
314
|
+
|
|
315
|
+
async def get_summary(
|
|
316
|
+
self,
|
|
317
|
+
start: datetime,
|
|
318
|
+
end: datetime,
|
|
319
|
+
file_id: str | None = None,
|
|
320
|
+
category: str | None = None,
|
|
321
|
+
entity: str | None = None,
|
|
322
|
+
) -> SummaryResponse:
|
|
323
|
+
"""
|
|
324
|
+
Get download summary analytics.
|
|
325
|
+
|
|
326
|
+
Args:
|
|
327
|
+
start: Start datetime.
|
|
328
|
+
end: End datetime.
|
|
329
|
+
file_id: Optional file ID filter.
|
|
330
|
+
category: Optional category filter.
|
|
331
|
+
entity: Optional entity filter.
|
|
332
|
+
|
|
333
|
+
Returns:
|
|
334
|
+
SummaryResponse with aggregated statistics.
|
|
335
|
+
"""
|
|
336
|
+
params: dict[str, Any] = {
|
|
337
|
+
"start": start.isoformat(),
|
|
338
|
+
"end": end.isoformat(),
|
|
339
|
+
}
|
|
340
|
+
if file_id:
|
|
341
|
+
params["fileId"] = file_id
|
|
342
|
+
if category:
|
|
343
|
+
params["category"] = category
|
|
344
|
+
if entity:
|
|
345
|
+
params["entity"] = entity
|
|
346
|
+
|
|
347
|
+
response = await self._client.get("/analytics/summary", params=params)
|
|
348
|
+
data = self._handle_response(response)
|
|
349
|
+
return SummaryResponse.model_validate(data)
|
|
350
|
+
|
|
351
|
+
async def get_downloads_by_ip(
|
|
352
|
+
self,
|
|
353
|
+
ip_address: str,
|
|
354
|
+
start: datetime,
|
|
355
|
+
end: datetime,
|
|
356
|
+
) -> DownloadsByIpResponse:
|
|
357
|
+
"""
|
|
358
|
+
Get downloads by IP address.
|
|
359
|
+
|
|
360
|
+
Args:
|
|
361
|
+
ip_address: The IP address to query.
|
|
362
|
+
start: Start datetime.
|
|
363
|
+
end: End datetime.
|
|
364
|
+
|
|
365
|
+
Returns:
|
|
366
|
+
DownloadsByIpResponse with download records.
|
|
367
|
+
"""
|
|
368
|
+
params = {
|
|
369
|
+
"start": start.isoformat(),
|
|
370
|
+
"end": end.isoformat(),
|
|
371
|
+
}
|
|
372
|
+
response = await self._client.get(f"/analytics/by-ip/{ip_address}", params=params)
|
|
373
|
+
data = self._handle_response(response)
|
|
374
|
+
return DownloadsByIpResponse.model_validate(data)
|
|
375
|
+
|
|
376
|
+
async def get_user_agents(
|
|
377
|
+
self,
|
|
378
|
+
start: datetime,
|
|
379
|
+
end: datetime,
|
|
380
|
+
) -> UserAgentsResponse:
|
|
381
|
+
"""
|
|
382
|
+
Get user agent analytics.
|
|
383
|
+
|
|
384
|
+
Args:
|
|
385
|
+
start: Start datetime.
|
|
386
|
+
end: End datetime.
|
|
387
|
+
|
|
388
|
+
Returns:
|
|
389
|
+
UserAgentsResponse with user agent counts.
|
|
390
|
+
"""
|
|
391
|
+
params = {
|
|
392
|
+
"start": start.isoformat(),
|
|
393
|
+
"end": end.isoformat(),
|
|
394
|
+
}
|
|
395
|
+
response = await self._client.get("/analytics/user-agents", params=params)
|
|
396
|
+
data = self._handle_response(response)
|
|
397
|
+
return UserAgentsResponse.model_validate(data)
|
|
398
|
+
|
|
399
|
+
# Maintenance
|
|
400
|
+
|
|
401
|
+
async def cleanup_downloads(self) -> CleanupResponse:
|
|
402
|
+
"""
|
|
403
|
+
Clean up old download records.
|
|
404
|
+
|
|
405
|
+
Returns:
|
|
406
|
+
CleanupResponse with deleted count.
|
|
407
|
+
"""
|
|
408
|
+
response = await self._client.post("/maintenance/cleanup-downloads")
|
|
409
|
+
data = self._handle_response(response)
|
|
410
|
+
return CleanupResponse.model_validate(data)
|
|
411
|
+
|
|
412
|
+
# Health
|
|
413
|
+
|
|
414
|
+
async def health(self) -> HealthResponse:
|
|
415
|
+
"""
|
|
416
|
+
Check API health.
|
|
417
|
+
|
|
418
|
+
Returns:
|
|
419
|
+
HealthResponse with status and timestamp.
|
|
420
|
+
"""
|
|
421
|
+
response = await self._client.get("/health")
|
|
422
|
+
data = self._handle_response(response)
|
|
423
|
+
return HealthResponse.model_validate(data)
|
|
424
|
+
|
|
425
|
+
# High-Level Pipeline
|
|
426
|
+
|
|
427
|
+
async def upload_and_register(
|
|
428
|
+
self,
|
|
429
|
+
file_path: str | Path,
|
|
430
|
+
category: str,
|
|
431
|
+
entity: str,
|
|
432
|
+
remote_path: str,
|
|
433
|
+
remote_filename: str,
|
|
434
|
+
remote_version: str,
|
|
435
|
+
name: str | None = None,
|
|
436
|
+
tags: list[str] | None = None,
|
|
437
|
+
extra: dict[str, Any] | None = None,
|
|
438
|
+
content_type: str | None = None,
|
|
439
|
+
progress_callback: Callable[[int], None] | None = None,
|
|
440
|
+
) -> FileRecord:
|
|
441
|
+
"""
|
|
442
|
+
Upload a file to R2 and register it with the r2index API asynchronously.
|
|
443
|
+
|
|
444
|
+
This is a convenience method that performs the full pipeline:
|
|
445
|
+
1. Compute checksums (streaming, memory efficient)
|
|
446
|
+
2. Upload to R2 (multipart for large files)
|
|
447
|
+
3. Register with r2index API
|
|
448
|
+
|
|
449
|
+
Args:
|
|
450
|
+
file_path: Path to the file to upload.
|
|
451
|
+
category: File category.
|
|
452
|
+
entity: File entity.
|
|
453
|
+
remote_path: Remote path in R2 (e.g., "/data/files").
|
|
454
|
+
remote_filename: Remote filename in R2.
|
|
455
|
+
remote_version: Version identifier.
|
|
456
|
+
name: Optional display name.
|
|
457
|
+
tags: Optional list of tags.
|
|
458
|
+
extra: Optional extra metadata.
|
|
459
|
+
content_type: Optional content type for R2.
|
|
460
|
+
progress_callback: Optional callback for upload progress.
|
|
461
|
+
|
|
462
|
+
Returns:
|
|
463
|
+
The created FileRecord.
|
|
464
|
+
|
|
465
|
+
Raises:
|
|
466
|
+
R2IndexError: If R2 config is not provided.
|
|
467
|
+
UploadError: If upload fails.
|
|
468
|
+
"""
|
|
469
|
+
file_path = Path(file_path)
|
|
470
|
+
uploader = self._get_uploader()
|
|
471
|
+
|
|
472
|
+
# Step 1: Compute checksums
|
|
473
|
+
checksums = await compute_checksums_async(file_path)
|
|
474
|
+
|
|
475
|
+
# Step 2: Build R2 object key
|
|
476
|
+
object_key = f"{remote_path.strip('/')}/{remote_filename}"
|
|
477
|
+
|
|
478
|
+
# Step 3: Upload to R2
|
|
479
|
+
await uploader.upload_file(
|
|
480
|
+
file_path,
|
|
481
|
+
object_key,
|
|
482
|
+
content_type=content_type,
|
|
483
|
+
progress_callback=progress_callback,
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
# Step 4: Register with API
|
|
487
|
+
create_request = FileCreateRequest(
|
|
488
|
+
category=category,
|
|
489
|
+
entity=entity,
|
|
490
|
+
remote_path=remote_path,
|
|
491
|
+
remote_filename=remote_filename,
|
|
492
|
+
remote_version=remote_version,
|
|
493
|
+
name=name,
|
|
494
|
+
tags=tags,
|
|
495
|
+
extra=extra,
|
|
496
|
+
size=checksums.size,
|
|
497
|
+
md5=checksums.md5,
|
|
498
|
+
sha1=checksums.sha1,
|
|
499
|
+
sha256=checksums.sha256,
|
|
500
|
+
sha512=checksums.sha512,
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
return await self.create_file(create_request)
|